mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-17 18:55:37 +08:00
feat: render health pages from server summaries
This commit is contained in:
1 parent
c9ca625de1
commit
bcd4cd3759
9 files changed
+241
-103
No files matched your search
@@ -8,22 +8,24 @@ function fixture(){
|
||||
const records=Array.from({length:3000},(_,i)=>({timestamp:`2026-10-16T09:${String(i%60).padStart(2,'0')}:00+08:00`,stress_score:30}));
|
||||
const responses={summary:[{date:'2026-10-16',steps:6000,active_kcal:500,distance_m:4000}],
|
||||
'heart-rate':[{bpm:62}],sleep:[{end_at:'2026-10-16T08:00:00+08:00',duration_minutes:450}],spo2:[{spo2_pct:97}],stress:records};
|
||||
const copied=[];
|
||||
const copied=[],requests=[];
|
||||
const context=vm.createContext({document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){}},
|
||||
localStorage:{getItem(){return '';},setItem(){}},performance,console,setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},
|
||||
AbortController,localStorage:{getItem(){return '';},setItem(){}},performance,console,setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},
|
||||
navigator:{clipboard:{writeText(text){copied.push(text);return Promise.resolve();}}},
|
||||
fetch:async url=>({status:200,ok:true,text:async()=>JSON.stringify({status:'ok',data:responses[url.split('?')[0].split('/').pop()]||[]})})});
|
||||
fetch:async url=>{requests.push(url);return {status:200,ok:true,text:async()=>JSON.stringify({status:'ok',data:url.startsWith('/proxy/api/overview?')?{steps:6000,active_kcal:500,distance_m:4000,resting_mean_bpm:62,mean_daily_asleep_minutes:450,mean_spo2_pct:97,mean_stress:30,activity_days:1,sleep_days:1,resting_count:1,spo2_count:1}:responses[url.split('?')[0].split('/').pop()]||[]})};}});
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),context);
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/app.js','utf8'),context);
|
||||
vm.runInContext('showToast=()=>{}; renderCount=0; originalRender=renderJsonViewer; renderJsonViewer=(data)=>{renderCount++;originalRender(data);};',context);
|
||||
return {context,get,copied};
|
||||
return {context,get,copied,requests};
|
||||
}
|
||||
test('overview paints one small response instead of five full datasets',async()=>{
|
||||
const ui=fixture();await vm.runInContext('loadOverview()',ui.context);
|
||||
assert.equal(vm.runInContext('renderCount',ui.context),1);
|
||||
assert.equal(ui.get('statStress').textContent,'30');
|
||||
assert.equal(ui.get('statSteps').textContent,(6000).toLocaleString());
|
||||
assert.equal(vm.runInContext('state.lastRequestInfo.path.split("?")[0]',ui.context),'/api/summary');
|
||||
assert.equal(vm.runInContext('state.lastRequestInfo.path.split("?")[0]',ui.context),'/api/overview');
|
||||
assert.deepEqual(ui.requests,['/proxy/api/overview?start_date=2026-09-18&end_date=2026-10-17']);
|
||||
assert.ok(JSON.stringify(vm.runInContext('state.lastResponse',ui.context)).length<500);
|
||||
});
|
||||
test('silent and superseded requests do not overwrite inspector state',async()=>{
|
||||
const ui=fixture();ui.get('inspectorBody').innerHTML='keep-current';
|
||||
|
||||
+7
-13
@@ -14,21 +14,17 @@ function dashboard(responses) {
|
||||
const context = vm.createContext({
|
||||
localStorage: {getItem() {return '';}, setItem() {}},
|
||||
document: {getElementById: get, querySelectorAll() {return [];}, addEventListener() {}},
|
||||
responses, console, setTimeout() {}, clearTimeout() {}, setInterval() {}, clearInterval() {},
|
||||
responses, console, AbortController, setTimeout() {}, clearTimeout() {}, setInterval() {}, clearInterval() {},
|
||||
});
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),context);
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), context);
|
||||
vm.runInContext("showToast = () => {}; api = async (method, url) => responses[url.split('?')[0]] || {status:'ok',data:[]};", context);
|
||||
vm.runInContext("showToast = () => {}; api = async (method, url) => responses[url.split('?')[0]] || {status:'ok',data:{steps:null,active_kcal:null,distance_m:null,resting_mean_bpm:null,mean_daily_asleep_minutes:null,mean_spo2_pct:null,mean_stress:null,activity_days:0,sleep_days:0,resting_count:0,spo2_count:0}};", context);
|
||||
return {context, get};
|
||||
}
|
||||
|
||||
test('overview aggregates the selected range instead of taking the latest day', async () => {
|
||||
const ui = dashboard({
|
||||
'/api/summary': {status:'ok',data:[{date:'2026-10-16',steps:4063,active_kcal:250,distance_m:2385},{date:'2026-10-15',steps:4691,active_kcal:596,distance_m:2979}]},
|
||||
'/api/heart-rate': {status:'ok',data:[{timestamp:'2026-10-16T09:00:00+08:00',bpm:62}]},
|
||||
'/api/sleep': {status:'ok',data:[{end_at:'2026-10-16T08:54:00+08:00',duration_minutes:517},{end_at:'2026-10-15T07:55:00+08:00',duration_minutes:474}]},
|
||||
'/api/spo2': {status:'ok',data:[{timestamp:'2026-10-16T09:00:00+08:00',spo2_pct:98}]},
|
||||
'/api/stress': {status:'ok',data:[{timestamp:'2026-10-16T09:00:00+08:00',stress_score:0}]},
|
||||
'/api/overview': {status:'ok',data:{steps:8754,active_kcal:846,distance_m:5364,resting_mean_bpm:62,mean_daily_asleep_minutes:495.5,mean_spo2_pct:98,mean_stress:0,activity_days:2,sleep_days:2,resting_count:1,spo2_count:1}},
|
||||
});
|
||||
await vm.runInContext('loadOverview()',ui.context);
|
||||
assert.equal(ui.get('statSteps').textContent, (8754).toLocaleString());
|
||||
@@ -40,12 +36,9 @@ test('overview aggregates the selected range instead of taking the latest day',
|
||||
assert.equal(String(ui.get('statStress').textContent),'0');
|
||||
});
|
||||
|
||||
test('range averages include all samples and group naps by wake date without filling missing days', async () => {
|
||||
test('overview displays server range averages and wake-day coverage without filling missing days', async () => {
|
||||
const ui=dashboard({
|
||||
'/api/heart-rate':{status:'ok',data:[{bpm:60},{bpm:70}]},
|
||||
'/api/spo2':{status:'ok',data:[{spo2_pct:96},{spo2_pct:98}]},
|
||||
'/api/stress':{status:'ok',data:[{stress_score:0},{stress_score:60}]},
|
||||
'/api/sleep':{status:'ok',data:[{end_at:'2026-10-15T07:55:00+08:00',duration_minutes:474},{end_at:'2026-10-16T08:54:00+08:00',duration_minutes:517},{end_at:'2026-10-16T14:00:00+08:00',duration_minutes:60,is_nap:true}]},
|
||||
'/api/overview':{status:'ok',data:{steps:null,active_kcal:null,distance_m:null,resting_mean_bpm:65,mean_spo2_pct:97,mean_stress:30,mean_daily_asleep_minutes:525.5,activity_days:0,sleep_days:2,resting_count:2,spo2_count:2}},
|
||||
});
|
||||
ui.get('startDate').value='2026-10-01';
|
||||
await vm.runInContext('loadOverview()',ui.context);
|
||||
@@ -63,7 +56,8 @@ test('30 day preset is inclusive and refreshes the overview without manual reloa
|
||||
assert.equal(ui.get('startDate').value,'2026-09-17');
|
||||
assert.equal(ui.get('endDate').value,'2026-10-16');
|
||||
const calls=vm.runInContext('calls',ui.context);
|
||||
assert.equal(calls.length,5);
|
||||
assert.equal(calls.length,1);
|
||||
assert.equal(calls[0],'/api/overview?start_date=2026-09-17&end_date=2026-10-16');
|
||||
assert.ok(calls.every(path=>!path.includes('limit=1')));
|
||||
await vm.runInContext("setDatePreset('yesterday')",ui.context);
|
||||
assert.equal(ui.get('startDate').value,'2026-10-15');
|
||||
|
||||
@@ -3,17 +3,18 @@ const assert=require('node:assert/strict');
|
||||
const vm=require('node:vm');
|
||||
const fs=require('node:fs');
|
||||
const HealthViews=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js');
|
||||
function summary(kind='sleep',rows=[]){return {status:'ok',kind,statistics:{count:rows.length,mean_daily_asleep_minutes:400,mean_daily_deep_minutes:null,nap_count:0},coverage:rows.length,stride:1,points:[{date:'2026-10-16',values:[null,null,null,400/60],missing:false}],page:{number:1,size:10,total:rows.length,rows}};}
|
||||
function fixture(){
|
||||
const nodes=new Map(),saved={};let callback;
|
||||
const get=id=>{if(!nodes.has(id))nodes.set(id,{value:({startDate:'2026-10-15',endDate:'2026-10-17',napsSelect:'true'})[id]||'',textContent:'',innerHTML:'',checked:false,dataset:{},setAttribute(k,v){this[k]=v;},classList:{toggle(){},add(){},remove(){}}});return nodes.get(id);};
|
||||
const c=vm.createContext({HealthViews,AbortController,console,performance,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(name,fn){callback=fn;},documentElement:{setAttribute(){}}},localStorage:{getItem(k){return saved[k]||'';},setItem(k,v){saved[k]=v;}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){}});
|
||||
const c=vm.createContext({HealthViews,summary,AbortController,console,performance,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(name,fn){callback=fn;},documentElement:{setAttribute(){}}},localStorage:{getItem(k){return saved[k]||'';},setItem(k,v){saved[k]=v;}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){}});
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),c);
|
||||
vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/app.js','utf8'),c);
|
||||
vm.runInContext('showToast=()=>{};',c);
|
||||
return{c,get,saved};
|
||||
}
|
||||
test('切换到睡眠页自动查询并清空上个接口,修改日期会重新加载',async()=>{
|
||||
const f=fixture();vm.runInContext('calls=[];api=async(method,path)=>{calls.push(path);return {status:"ok",data:[]};};state.lastResponse={old:true};',f.c);
|
||||
const f=fixture();vm.runInContext('calls=[];api=async(method,path)=>{calls.push(path);return summary();};state.lastResponse={old:true};',f.c);
|
||||
await vm.runInContext("switchTab('sleep')",f.c);
|
||||
assert.equal(vm.runInContext('state.lastResponse',f.c),null);
|
||||
assert.equal(vm.runInContext('calls.length',f.c),1);
|
||||
@@ -28,15 +29,15 @@ test('后返回的旧日期请求不会覆盖新日期结果或控制台',async(
|
||||
f.get('startDate').value='2026-10-16';
|
||||
const second=vm.runInContext('querySleep()',f.c);
|
||||
assert.equal(waits[0].view.isCurrent(),false);
|
||||
waits[1].resolve({status:'ok',data:[{end_at:'2026-10-16T08:00:00+08:00',time_asleep_minutes:400}]});await second;
|
||||
waits[0].resolve({status:'ok',data:[{end_at:'2026-10-15T08:00:00+08:00',time_asleep_minutes:100}]});await first;
|
||||
waits[1].resolve(summary('sleep',[{end_at:'2026-10-16T08:00:00+08:00',time_asleep_minutes:400}]));await second;
|
||||
waits[0].resolve(summary('sleep',[{end_at:'2026-10-15T08:00:00+08:00',time_asleep_minutes:100}]));await first;
|
||||
assert.equal(vm.runInContext('state.healthViews.sleep.model.records[0].time_asleep_minutes',f.c),400);
|
||||
});
|
||||
test('失败与无数据分别显示,控制台开关会记忆选择',async()=>{
|
||||
const f=fixture();vm.runInContext('state.activeTab="spo2";api=async()=>null;',f.c);
|
||||
await vm.runInContext('querySpo2()',f.c);
|
||||
assert.match(f.get('healthView-spo2').innerHTML,/查询失败/);
|
||||
vm.runInContext('api=async()=>({status:"ok",data:[]});',f.c);
|
||||
vm.runInContext('api=async()=>summary("spo2");',f.c);
|
||||
await vm.runInContext('querySpo2()',f.c);
|
||||
assert.match(f.get('healthView-spo2').innerHTML,/未保存/);
|
||||
vm.runInContext('setInspectorCollapsed(true)',f.c);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const vm=require('node:vm');
|
||||
const fs=require('node:fs');
|
||||
const HealthViews=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js');
|
||||
function summary(kind='stress',number=1,total=21){return {status:'ok',kind,range:{start:'2026-10-15',end:'2026-10-17'},statistics:{count:total,primary_mean:33,primary_min:10,primary_max:60},coverage:1,stride:1,points:[{date:'2026-10-16',values:[33],missing:false}],page:{number,size:10,total,rows:[{timestamp:'2026-10-16T00:00:00Z',stress_score:number}]}};}
|
||||
function fixture(){
|
||||
const nodes=new Map(),links=[],requests=[],calendars=[];
|
||||
const get=id=>{if(!nodes.has(id))nodes.set(id,{value:({startDate:'2026-10-15',endDate:'2026-10-17',napsSelect:'true'})[id]||'',textContent:'',innerHTML:'',checked:false,setAttribute(){},classList:{toggle(){},add(){},remove(){}},querySelector(){return get(id+'-table');}});return nodes.get(id);};
|
||||
const context=vm.createContext({HealthViews,AbortController,console,performance,URL,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){},createElement(){const a={click(){links.push(this.href);},remove(){}};return a;},body:{appendChild(){}}},localStorage:{getItem(){return '';},setItem(){}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},fetch:url=>new Promise(resolve=>{if(url.startsWith('/api/calendar'))calendars.push({url,resolve});else requests.push({url,resolve});})});
|
||||
for(const name of ['health-ui','app'])vm.runInContext(fs.readFileSync(`src/mi_fitness_mcp/web_assets/static/js/${name}.js`,'utf8'),context);
|
||||
vm.runInContext('showToast=()=>{};state.activeTab="stress";',context);
|
||||
const reply=(i,p)=>requests[i].resolve({status:200,ok:true,text:async()=>JSON.stringify(p)});
|
||||
return {context,get,links,requests,calendars,reply,run:code=>vm.runInContext(code,context)};
|
||||
}
|
||||
test('test_pagination_fetches_only_page_and_preserves_chart',async()=>{
|
||||
const f=fixture(),initial=f.run('loadHealthView("stress")');assert.match(f.requests[0].url,/^\/proxy\/api\/views\/stress\?/);f.reply(0,summary());await initial;
|
||||
const model=f.run('state.healthViews.stress.model'),html=f.get('healthView-stress').innerHTML;
|
||||
const second=f.run('healthPage("stress",2)');assert.match(f.requests[1].url,/\/views\/stress\/records\?.*page=2/);
|
||||
const p=summary('stress',2);delete p.statistics;delete p.points;f.reply(1,p);await second;
|
||||
assert.equal(f.run('state.healthViews.stress.model'),model);assert.equal(model.cards[0].raw,33);assert.equal(f.get('healthView-stress').innerHTML,html);assert.match(f.get('healthView-stress-table').innerHTML,/2 \/ 3/);
|
||||
f.run('exportHealthRecords("stress")');assert.match(f.links[0],/^\/proxy\/api\/views\/stress\/export\?/);assert.doesNotMatch(f.links[0],/page=/);
|
||||
});
|
||||
test('test_late_page_and_overview_responses_do_not_overwrite_new_range',async()=>{
|
||||
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
|
||||
const old=f.run('healthPage("stress",2)');f.get('startDate').value='2026-10-16';const fresh=f.run('loadHealthView("stress")');f.reply(2,summary('stress',1,5));await fresh;
|
||||
const html=f.get('healthView-stress').innerHTML,inspector=f.get('inspectorBody').innerHTML;
|
||||
f.reply(1,summary('stress',2));await old;f.calendars[0].resolve({ok:true,json:async()=>({years:{old:true}})});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(f.get('healthView-stress').innerHTML,html);assert.equal(f.get('inspectorBody').innerHTML,inspector);assert.equal(f.run('state.healthViews.stress.model.total'),5);
|
||||
f.run('state.activeTab="overview"');const overview=f.run('loadOverview()');f.get('endDate').value='2026-10-16';f.reply(3,{status:'ok',data:{steps:999}});await overview;assert.equal(f.get('statSteps').textContent,'--');assert.equal(f.get('inspectorBody').innerHTML.includes('999'),false);
|
||||
});
|
||||
test('switching type and filters rejects pending page results, latest page wins',async()=>{
|
||||
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
|
||||
const a=f.run('healthPage("stress",2)'),b=f.run('healthPage("stress",3)');f.reply(2,summary('stress',3));await b;f.reply(1,summary('stress',2));await a;assert.equal(f.run('state.healthViews.stress.page'),3);
|
||||
const pending=f.run('healthPage("stress",2)');f.get('stressLevel').value='mild';f.run('state.activeTab="sleep"');f.reply(3,summary('stress',2));await pending;assert.equal(f.run('state.healthViews.stress.page'),3);
|
||||
});
|
||||
test('shrinking totals re-fetch the last valid page instead of showing false empty',async()=>{
|
||||
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
|
||||
const pending=f.run('healthPage("stress",3)');const p=summary('stress',3,11);p.page.rows=[];f.reply(1,p);await new Promise(resolve=>setImmediate(resolve));assert.match(f.requests[2].url,/page=2/);f.reply(2,summary('stress',2,11));await pending;assert.equal(f.run('state.healthViews.stress.page'),2);assert.equal(f.run('state.healthViews.stress.model.total'),11);
|
||||
});
|
||||
test('test_empty_and_failure_remain_distinct',async()=>{
|
||||
const f=fixture();let p=f.run('loadHealthView("stress")');f.reply(0,{status:'error'});await p;assert.match(f.get('healthView-stress').innerHTML,/查询失败/);
|
||||
p=f.run('loadHealthView("stress")');const empty=summary('stress',1,0);empty.page.rows=[];f.reply(1,empty);await p;assert.match(f.get('healthView-stress').innerHTML,/未保存/);assert.doesNotMatch(f.get('healthView-stress').innerHTML,/查询失败/);
|
||||
});
|
||||
test('filters use exact Beijing dates and server selection parameters for all eight views',async()=>{
|
||||
const f=fixture();f.get('hrType').value='resting';f.get('hrLimit').value='25';f.get('stressLevel').value='unclassified';f.get('stressLimit').value='12';f.get('latestOnlyCheck').checked=true;
|
||||
for(const kind of HealthViews.kinds){f.context.kind=kind;f.run('state.activeTab=kind');const pending=f.run('loadHealthView(kind)');const i=f.requests.length-1;const u=new URL(f.requests[i].url,'http://local');assert.equal(u.pathname,'/proxy/api/views/'+kind);assert.equal(u.searchParams.get('start_date'),'2026-10-15');assert.equal(u.searchParams.get('end_date'),'2026-10-17');
|
||||
if(kind==='heartrate'){assert.equal(u.searchParams.get('limit'),'25');assert.equal(u.searchParams.get('sample_type'),'resting');}if(kind==='stress'){assert.equal(u.searchParams.get('pressure_level'),'unclassified');assert.equal(u.searchParams.get('limit'),'12');}if(kind==='body')assert.equal(u.searchParams.get('latest_only'),'true');
|
||||
f.reply(i,summary(kind));await pending;}
|
||||
});
|
||||
|
||||
for(const change of ['date','type','filter','limit'])test(`pending pages and calendars reject changed ${change} selection`,async()=>{
|
||||
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
|
||||
const page=f.run('healthPage("stress",2)'),model=f.run('state.healthViews.stress.model');
|
||||
if(change==='date')f.get('endDate').value='2026-10-16';
|
||||
if(change==='type')f.run('state.activeTab="sleep"');
|
||||
if(change==='filter')f.get('stressLevel').value='mild';
|
||||
if(change==='limit')f.get('stressLimit').value='5';
|
||||
const html=f.get('healthView-stress').innerHTML,inspector=f.get('inspectorBody').innerHTML;
|
||||
f.reply(1,summary('stress',2));await page;f.calendars[0].resolve({ok:true,json:async()=>({years:{unexpected:true}})});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(f.run('state.healthViews.stress.page'),1);assert.equal(model.range.calendar,undefined);assert.equal(f.get('healthView-stress').innerHTML,html);assert.equal(f.get('inspectorBody').innerHTML,inspector);
|
||||
});
|
||||
test('a current calendar can update date marks after paging without reverting rows',async()=>{
|
||||
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
|
||||
const next=f.run('healthPage("stress",2)');f.reply(1,summary('stress',2));await next;
|
||||
f.calendars[0].resolve({ok:true,json:async()=>({years:{'2026':{days:[{date:'2026-10-16',isOffDay:true}],papers:['synthetic']}}})});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(f.run('state.healthViews.stress.page'),2);assert.equal(f.run('state.healthViews.stress.model.records[0].stress_score'),2);assert.match(f.get('healthView-stress').innerHTML,/2 \/ 3/);assert.match(f.get('healthView-stress').innerHTML,/stroke="#22a06b"/);
|
||||
});
|
||||
test('a failed page leaves summary intact and allows retrying the same page',async()=>{
|
||||
const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial;
|
||||
const html=f.get('healthView-stress').innerHTML;
|
||||
let next=f.run('healthPage("stress",2)');f.reply(1,{status:'error'});await next;assert.match(f.get('healthView-stress-table').innerHTML,/明细查询失败/);assert.equal(f.get('healthView-stress').innerHTML,html);assert.equal(f.run('state.healthViews.stress.page'),1);
|
||||
next=f.run('healthPage("stress",2)');f.reply(2,summary('stress',2));await next;assert.equal(f.run('state.healthViews.stress.page'),2);assert.doesNotMatch(f.get('healthView-stress-table').innerHTML,/查询失败/);
|
||||
});
|
||||
test('out-of-order overview loads preserve new range values and inspector',async()=>{
|
||||
const f=fixture();f.run('state.activeTab="overview"');const old=f.run('loadOverview()');f.get('startDate').value='2026-10-16';const current=f.run('loadOverview()');
|
||||
f.reply(1,{status:'ok',data:{steps:42}});await current;const inspector=f.get('inspectorBody').innerHTML;
|
||||
f.reply(0,{status:'ok',data:{steps:999}});await old;assert.equal(f.get('statSteps').textContent,'42');assert.equal(f.get('inspectorBody').innerHTML,inspector);assert.match(f.get('overviewRange').textContent,/2026-10-16 至 2026-10-17/);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const H=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js');
|
||||
const range={start:'2026-10-01',end:'2026-10-30'};
|
||||
function payload(kind,statistics,total=90000){return {status:'ok',kind,statistics,coverage:23,stride:1,points:Array.from({length:30},(_,i)=>({date:`2026-10-${String(i+1).padStart(2,'0')}`,values:[40],missing:false})),page:{number:1,size:10,total,rows:Array.from({length:10},()=>({timestamp:'2026-10-15T00:00:00Z',stress_score:1,bpm:1,weight_kg:1,steps:1}))}};}
|
||||
test('test_server_summary_drives_cards_without_raw_records',()=>{
|
||||
const m=H.fromSummary('stress',payload('stress',{count:90000,primary_mean:47.5,primary_min:0,primary_max:120}),range);
|
||||
assert.equal(m.cards[0].raw,47.5);assert.equal(m.cards[1].raw,0);assert.equal(m.cards[2].raw,120);assert.equal(m.cards[3].raw,23);
|
||||
assert.equal(m.references.find(r=>r.key==='average').value,47.5);assert.equal(m.records.length,10);assert.equal(m.total,90000);assert.equal(m.points.length,30);
|
||||
assert.match(H.detail(m),/90,000/);assert.match(H.detail(m),/1 \/ 9000/);assert.match(H.render(m),/health-calendar-mark/);
|
||||
});
|
||||
test('all eight kinds map numeric server statistics and latest projections',()=>{
|
||||
const cases={activity:[{primary_sum:800,total_active_kcal:50,total_distance_m:2500,mean_daily_steps:400},[800,50,2.5,23]],heartrate:[{passive_mean_bpm:70,resting_mean_bpm:60,primary_min:40,primary_max:180,primary_mean:69},[70,60,40,180]],sleep:[{mean_daily_asleep_minutes:450,mean_daily_deep_minutes:90,nap_count:3},[450,23,90,3]],workouts:[{count:17,primary_sum:600,total_distance_m:9000,total_calories_kcal:500},[17,600,9,500]],body:[{count:18,primary_mean:64,latest:{weight_kg:65,bmi:22,body_fat_pct:19}},[65,22,19,18]],spo2:[{primary_mean:97,primary_min:89,primary_max:100},[97,89,100,23]],abnormal:[{count:20,primary_sum:90,latest:{start_at:'2026-10-15T00:00:00Z'}},[20,90,23,null]]};
|
||||
for(const [kind,[stats,expected]] of Object.entries(cases)){
|
||||
const m=H.fromSummary(kind,payload(kind,stats),range);assert.deepEqual(m.cards.map(c=>c.raw),expected,kind);
|
||||
if(['body','spo2','heartrate'].includes(kind))assert.equal(H.referenceLayout(m).min,{body:40,spo2:80,heartrate:40}[kind]);
|
||||
}
|
||||
});
|
||||
test('server total governs empty chart even when a requested detail page has no rows',()=>{
|
||||
const p=payload('stress',{count:90000,primary_mean:null});p.page.rows=[];
|
||||
const m=H.fromSummary('stress',p,range);assert.match(H.render(m),/<svg/);assert.doesNotMatch(H.render(m),/未保存/);assert.ok(!m.references.some(r=>r.key==='average'));
|
||||
p.page.total=0;p.statistics.count=0;assert.doesNotMatch(H.render(H.fromSummary('stress',p,range)),/<svg/);
|
||||
const b=H.fromSummary('body',payload('body',{count:0,latest:null},0),range);assert.equal(b.cards[0].value,'—');
|
||||
});
|
||||
|
||||
test('server points, fixed references and two-color date marks keep the existing renderer',()=>{
|
||||
const calendar={'2026':{days:[{date:'2026-10-01',isOffDay:true},{date:'2026-10-10',isOffDay:false}],papers:['synthetic']}};
|
||||
const p=payload('heartrate',{primary_mean:65,resting_mean_bpm:60,passive_mean_bpm:70,primary_min:40,primary_max:75});
|
||||
p.points=p.points.map(point=>({...point,values:[70,60,null]}));
|
||||
const m=H.fromSummary('heartrate',p,{...range,calendar}),html=H.render(m);
|
||||
assert.equal(m.points,p.points);assert.equal(H.referenceLayout(m).min,40);assert.equal(H.referenceLayout(m).max,70);assert.deepEqual(H.referenceLayout(m).outside.map(r=>r.value),[170]);
|
||||
const marks=[...html.matchAll(/<line class="health-calendar-mark"[^>]+/g)].map(x=>x[0]);assert.equal(marks.length,30);assert.ok(marks.every(x=>x.includes('y1="207" y2="207"')));assert.ok(marks[0].includes('stroke="#22a06b"'));assert.ok(marks[9].includes('stroke="#e78325"'));
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user