feat: render health pages from server summaries

This commit is contained in:
weiyuqing committed 2026-10-17 14:10:27 +08:00
1 parent c9ca625de1
commit bcd4cd3759
9 files changed
+241 -103

No files matched your search

+25 -47
View File
@@ -372,55 +372,33 @@ function sleepDays(rows) {
}
async function loadOverview() {
const requestId = ++state.overviewRequestId;
const requestId = ++state.overviewRequestId, start=getSd(), end=getEd();
if(state.overviewController)state.overviewController.abort();
const controller=new AbortController();state.overviewController=controller;
const isCurrent=()=>requestId===state.overviewRequestId&&state.activeTab==='overview'&&getSd()===start&&getEd()===end;
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) {
$('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));
['statSteps', 'statCalories', 'statDistance', 'statHr', 'statSleep', 'statSpo2', 'statStress', 'statActivityCoverage', 'statSleepCoverage', 'statHrCoverage', 'statSpo2Coverage'].forEach(id => {$(id).textContent = '--';});
if(!start||!end||start>end){clearInspector();$('overviewRange').textContent='请选择有效的开始和结束日期。';return;}
const result=await api('GET',getRangeQuery('/api/overview'),null,{isCurrent,signal:controller.signal});
if(!isCurrent())return;
if(!result||result.status!=='ok'||!result.data||Array.isArray(result.data)){
$('overviewRange').textContent='概览查询失败,请重试或查看控制台。';
showToast('数据未加载','请查看接口错误;未用 0 替代失败数据','warning');return;
}
if (hr && Array.isArray(hr) && hr.length > 0) {
$('statHr').textContent = displayNumber(aggregateValue(hr, 'bpm', true), value => String(Math.round(value * 10) / 10));
}
if (sleep && Array.isArray(sleep) && sleep.length > 0) {
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 = displayNumber(aggregateValue(spo2, 'spo2_pct', true), value => `${Math.round(value * 10) / 10}%`);
}
if (stress && Array.isArray(stress) && stress.length > 0) {
$('statStress').textContent = displayNumber(aggregateValue(stress, 'stress_score', true), value => String(Math.round(value * 10) / 10));
}
const failed = responses.some(response => !response || (!Array.isArray(response) && response.status !== 'ok'));
showToast(failed ? '部分数据未加载' : '仪表盘已刷新', failed ? '请查看接口错误;未用 0 替代失败数据' : '已按所选日期汇总', failed ? 'warning' : 'success');
const data=result.data;
$('overviewRange').textContent = `${start} 至 ${end};缺失日期不按 0 计入均值`;
$('statActivityCoverage').textContent = `已记录 ${data.activity_days??0} 天`;
$('statSleepCoverage').textContent = `记录 ${data.sleep_days??0} 天 · 含小睡`;
$('statHrCoverage').textContent = `样本 ${data.resting_count??0} 条`;
$('statSpo2Coverage').textContent = `样本 ${data.spo2_count??0} 条`;
$('statSteps').textContent = displayNumber(data.steps,value=>value.toLocaleString());
$('statCalories').textContent = displayNumber(data.active_kcal,Math.round);
$('statDistance').textContent = displayNumber(data.distance_m,value=>(value/1000).toFixed(2));
$('statHr').textContent = displayNumber(data.resting_mean_bpm,value=>String(Math.round(value*10)/10));
$('statSleep').textContent = displayNumber(data.mean_daily_asleep_minutes,value=>(value/60).toFixed(1));
$('statSpo2').textContent = displayNumber(data.mean_spo2_pct,value=>`${Math.round(value*10)/10}%`);
$('statStress').textContent = displayNumber(data.mean_stress,value=>String(Math.round(value*10)/10));
showToast('仪表盘已刷新','已按所选日期汇总','success');
}
// Health Data Queries
@@ -3,6 +3,8 @@ function invalidateHealthQuery(){
++state.healthRequestId;
if(state.healthController)state.healthController.abort();
state.healthController=null;
if(state.healthPageController)state.healthPageController.abort();
state.healthPageController=null;
}
function clearInspector(){
++state.inspectorRequestId;
@@ -18,18 +20,14 @@ function setInspectorCollapsed(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);
}
function healthPath(kind,endpoint=''){
let query=getRangeQuery('/api/views/'+kind+endpoint);
const selection=healthSelection(kind);
if(kind==='heartrate')query+=getSelQuery('hrType','sample_type');
if(kind==='sleep')query+='&include_naps='+$('napsSelect').value;
if(kind==='body')query+='&latest_only=false';
if(kind==='body')query+='&latest_only='+selection.latestOnly;
if(kind==='stress'&&selection.pressureLevel)query+='&pressure_level='+encodeURIComponent(selection.pressureLevel);
if(selection.limit!==null)query+='&limit='+selection.limit;
return query;
}
function healthSelection(kind){
@@ -69,12 +67,12 @@ async function loadHealthView(kind){
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)){
if(!result||result.status!=='ok'||!result.statistics||!Array.isArray(result.points)||!result.page||!Array.isArray(result.page.rows)){
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};
const model=HealthViews.fromSummary(kind,result,{start,end,...selection});
state.healthViews[kind]={model,page:model.page,isCurrent,path};
container.innerHTML=HealthViews.render(model,1);
// 日历晚到只更新当前查询,健康记录不等待外部日历下载。
calendarRequest.then(calendar=>{
@@ -87,17 +85,33 @@ async function loadHealthView(kind){
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);
async function healthPage(kind,page){
const saved=state.healthViews[kind];if(!saved||!saved.isCurrent())return;
page=Math.min(1000000,Math.max(1,Math.min(Math.ceil(saved.model.total/10)||1,page)));
if(state.healthPageController)state.healthPageController.abort();
const controller=new AbortController();state.healthPageController=controller;
const requestId=state.healthPageRequestId=(state.healthPageRequestId||0)+1;
const isCurrent=()=>requestId===state.healthPageRequestId&&state.healthViews[kind]===saved&&saved.isCurrent();
// 同步期间总数可能减少;退到有效末页,不把越界页误报为无数据。
while(isCurrent()){
const result=await api('GET',healthPath(kind,'/records')+'&page='+page,null,{isCurrent,signal:controller.signal});
if(!isCurrent())return;
const target=$('healthView-'+kind).querySelector('.health-table-page');
if(!result||result.status!=='ok'||!result.page||!Array.isArray(result.page.rows)){
target.innerHTML='<div class="health-empty" role="alert">明细查询失败,请重试。</div>'+HealthViews.detail(saved.model,saved.page);return;
}
const last=Math.max(1,Math.ceil(result.page.total/10));
if(page>last){page=last;continue;}
saved.page=result.page.number;saved.model.page=saved.page;
saved.model.records=result.page.rows;saved.model.total=result.page.total;
target.innerHTML=HealthViews.detail(saved.model,saved.page);return;
}
}
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);
const saved=state.healthViews[kind];if(!saved||!saved.isCurrent())return;
const link=document.createElement('a');
link.href='/proxy'+healthPath(kind,'/export');link.download='';
document.body.appendChild(link);link.click();link.remove();
}
function initHealthViews(){
setInspectorCollapsed(localStorage.getItem('mi_fitness_inspector_collapsed')!=='false');
@@ -1,4 +1,4 @@
/* 健康数据展示:统计全部返回记录,图表和表格限制渲染数量。 */
/* 健康数据展示:页面消费服务端摘要和当前页,build 保留为基线辅助。 */
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
@@ -40,7 +40,7 @@
return {off:override?override.isOffDay:weekday===0||weekday===6,provisional:!known};
}
function calendarLegend(m){
if(!m.records.length)return '';
if(!recordTotal(m))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>`;
@@ -125,6 +125,43 @@
}
return {kind,...c,cards,legend,points,records:rows,range:{...range},coverage,stride,labels,cell,references,referenceNote};
}
function fromSummary(kind,payload,range){
const c=config[kind];if(!c)throw new Error('不支持的查询类型');
const s=payload.statistics,coverage=payload.coverage,latest=s.latest||{};
const distance=finite(s.total_distance_m)?s.total_distance_m/1000:null;
let cards,legend=[c.trend];
if(kind==='activity')cards=[card('期间总步数',s.primary_sum,'步'),card('活动消耗',s.total_active_kcal,'kcal'),card('累计距离',distance,'km'),card('记录覆盖',coverage,'天')];
else if(kind==='sleep'){
cards=[card('日均实睡',s.mean_daily_asleep_minutes,'','仅计算有记录的日期,含筛选后小睡',minutes),card('记录覆盖',coverage,'天'),card('日均深睡',s.mean_daily_deep_minutes,'','仅计算有深睡记录的日期',minutes),card('小睡记录',s.nap_count,'次')];
legend=['深睡','浅睡','REM','未分期'];
}else if(kind==='heartrate'){
cards=[card('被动心率均值',s.passive_mean_bpm,'bpm'),card('静息心率均值',s.resting_mean_bpm,'bpm'),card('最低心率',s.primary_min,'bpm'),card('最高心率',s.primary_max,'bpm')];
legend=['被动采样','静息','其他类型'];
}else if(kind==='workouts')cards=[card('运动次数',s.count,'次'),card('运动时长',s.primary_sum,'分钟'),card('累计距离',distance,'km'),card('运动热量',s.total_calories_kcal,'kcal')];
else if(kind==='body')cards=[card('最近体重',latest.weight_kg,'kg'),card('最近 BMI',latest.bmi),card('最近体脂率',latest.body_fat_pct,'%'),card('测量记录',s.count,'次',s.count<2?'不足两次测量,无法判断体重变化趋势':'')];
else if(kind==='abnormal')cards=[card('已保存事件',s.count,'次'),card('累计时长',s.primary_sum,'秒'),card('有记录日期',coverage,'天'),card('最近事件',null,'','',()=>s.count?stamp(latest.start_at):'未保存')];
else cards=[card(`平均${kind==='spo2'?'血氧':'压力'}`,s.primary_mean,c.unit),card('最低值',s.primary_min,c.unit),card('最高值',s.primary_max,c.unit),card('记录覆盖',coverage,'天')];
const references=[];
const reference=(key,label,value,color)=>{if(finite(value))references.push({key,label,value,color});};
let referenceNote='';
if(kind==='activity'){
reference('average','日均',s.mean_daily_steps,'#d97706');reference('fixed','参考',7000,'#22a06b');referenceNote='平均步数按有步数记录的日期计算。';
}else if(kind==='heartrate'){
reference('average','总平均',s.primary_mean,'#d97706');reference('resting-average','静息平均',s.resting_mean_bpm,'#9467cf');reference('fixed','参考',170,'#db5267');referenceNote='总平均包含全部筛选样本,静息平均仅计算静息记录。';
}else if(kind==='sleep'){
reference('average','日均实睡',finite(s.mean_daily_asleep_minutes)?s.mean_daily_asleep_minutes/60:null,'#d97706');reference('fixed','参考',8,'#22a06b');referenceNote='平均线按有实睡记录的醒来日计算,包含筛选后的小睡。';
}else if(kind==='body'){
reference('average','平均',s.primary_mean,'#d97706');reference('fixed','参考',65,'#22a06b');referenceNote='平均线使用全部筛选体重测量记录;柱状图保留每日最近一次测量。';
}else if(kind==='spo2'){
reference('average','平均',s.primary_mean,'#d97706');referenceNote='平均线按全部筛选血氧样本计算。';
}else if(kind==='stress'){
reference('average','平均',s.primary_mean,'#d97706');pressureLevels.slice(0,3).forEach(level=>reference('stress-'+level.max,level.label+'上界',level.max,level.color));referenceNote='平均线按全部筛选压力样本计算;0 或超出 1–100 的数值保留为未分类。';
}
const labels=Object.fromEntries(c.columns.map(([label,key])=>[key,label]));
const cell=(r,key)=>key==='_pressureBand'?pressureBand(r.stress_score).label:r[key];
return {kind,...c,cards,legend,points:payload.points,records:payload.page.rows,total:payload.page.total,page:payload.page.number,range:{...range},coverage,stride:payload.stride,labels,cell,references,referenceNote};
}
const recordTotal=m=>m.total??m.records.length;
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)));
@@ -138,7 +175,7 @@
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:[];
const layout=referenceLayout(m),outside=recordTotal(m)?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>`;
}
@@ -146,7 +183,7 @@
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>';
if(!recordTotal(m))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>`;}
@@ -176,9 +213,9 @@
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>`;
const total=recordTotal(m),pages=Math.max(1,Math.ceil(total/10));page=Math.min(pages,Math.max(1,m.total===undefined?page:m.page));
const slice=m.total===undefined?m.records.slice((page-1)*10,page*10):m.records;
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>${!total?'<div class="health-empty">NAS 在所选期间未保存此类记录</div>':''}<div class="health-pagination"><span>共 ${total.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} · 北京时间`;
@@ -192,5 +229,5 @@
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};
return {kinds,build,fromSummary,render,detail,csv,day,stamp,pressureBand,referenceLayout,calendarDay};
});
@@ -641,9 +641,9 @@
<div class="toast-container" id="toastContainer"></div>
<!-- Scripts -->
<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 src="/static/js/health-views.js?v=20261017-summary8"></script>
<script src="/static/js/health-ui.js?v=20261017-summary8"></script>
<script src="/static/js/app.js?v=20261017-summary8"></script>
<script>
function toggleApiKeyVisibility() {
const input = $('apiKeyInput');