原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外 的 10 个页面都有:健康、睡眠、运动、趋势、每日、身体成分、成绩预测、 身体年龄、挑战赛、运动详情。 不是给每个页面写一套,而是一个通用管线: - services/scopes.py:一个页面一个 context builder,返回同一个信封。 context["highlights"] 是已经算好的白话事实——模型负责解读它们,模型不 可用时规则引擎原样渲染。两者引用同一批数字,所以降级读起来不像换了个 App。 没数据的页面返回 None,宁可不出卡片,也不让模型对着空表格发挥。 - coach.scope_messages / parse_scope_insight:一套提示词吃所有页面,页面 的差异全在 context 里,加页面 = 加一个 builder。 - 前端 <AiPanel scope="…">:一个组件渲染所有页面,轮询逻辑抽成 lib/insight.ts 的 usePolledInsight,晨报卡也改用它。 ## 队列 一次生成 40 秒到 4.5 分钟,所以什么都不能在请求里生成。页面只负责入队, worker 负责消费(services/jobs.py)。 优先级才是用队列而不是后台线程的理由:同步完成后 prefetch 把所有页面按 背景优先级排进去,可能要跑半小时;而用户一打开某个页面,那个页面的任务 立刻提到队首、下一个就跑。你在看什么,队列就在算什么。 队列放在数据库而不是内存里,因为 gunicorn 有两个 worker:任务带 holder 声明后回读确认,和 scheduler.py 抢 tick 是同一套做法。id 由 user+kind+subject 推导,所以每几秒一次的轮询是幂等的入队,不会每几秒堆一 个任务。 ## 网关中断时踩到的两个坑(当场修了) 写完正好赶上 oracle 那台机器不通,于是看到: - 三次失败后任务被永久标 failed,网关恢复了也不会重试——一次瞬时中断就把 那个页面的解读判了死刑,直到它的数据碰巧变化。加了冷却期,过期后重置 尝试次数再排一次。 - 队列已经放弃了,页面还在 pending 转圈,要转满 8 分钟才停。meta.pending 现在跟着队列状态走,并把失败原因带给卡片。 顺带把 BAND_SOURCES 从 routes/settings.py 下沉到 services/insights.py: 教练要拿它做参照,而 services 不该反向依赖 routes。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
200 lines
7.0 KiB
TypeScript
200 lines
7.0 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Link, f7 } from 'framework7-react';
|
||
import { apiClient, errorMessage, FitnessAge, HealthDay } from '../services/api';
|
||
import { METRICS, metricHref } from '../lib/metrics';
|
||
import MetricCard from '../components/charts/MetricCard';
|
||
import Skeleton from '../components/Skeleton';
|
||
import Screen from '../components/Screen';
|
||
import AiPanel from '../components/AiPanel';
|
||
import { FEATURES } from '../features';
|
||
import { daysAgo, today as todayIso } from '../lib/day';
|
||
import './Health.css';
|
||
import './Settings.css';
|
||
|
||
const WINDOW_DAYS = 30;
|
||
|
||
/* Sections name metric ids; the labels, units and accessors come from the
|
||
registry so 今日 / 健康 / 趋势 cannot disagree about what a metric is. */
|
||
const SECTIONS: Array<{ title: string; items: string[] }> = [
|
||
{ title: '身体指标', items: ['heartRate', 'heartRateVariability', 'respirationAvg', 'spo2Avg'] },
|
||
{ title: '恢复', items: ['bodyBatteryHigh', 'stress', 'trainingReadiness', 'enduranceScore'] },
|
||
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] },
|
||
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
|
||
{ title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] },
|
||
{ title: '身体成分', items: ['weight', 'bodyFat', 'hydration', 'hillScore'] },
|
||
];
|
||
|
||
/* Screens that are not a single metric, so they get their own entries. */
|
||
const LINKS: Array<[string, string, string]> = [
|
||
['/body/', '身体成分与血压', '体重、体脂、肌肉量、血压记录'],
|
||
['/race/', '成绩预测', '5 公里到全马的预测完赛时间'],
|
||
['/challenges/', '挑战赛', '徽章挑战与好友挑战'],
|
||
];
|
||
|
||
function BodyAge({ data }: { data: FitnessAge | null }) {
|
||
if (!data) return null;
|
||
|
||
const go = () => f7.views.current.router.navigate('/body-age/');
|
||
|
||
if (data.value == null) {
|
||
return (
|
||
<section className="sec">
|
||
<h3 className="sec-title">身体年龄</h3>
|
||
<button className="bodyage bodyage-empty" onClick={go} type="button">
|
||
<span className="bodyage-missing">
|
||
还差 {data.missing.join('、')} 才能推算
|
||
</span>
|
||
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||
</button>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const delta = data.delta ?? 0;
|
||
|
||
return (
|
||
<section className="sec">
|
||
<h3 className="sec-title">身体年龄</h3>
|
||
<button className="bodyage" onClick={go} type="button">
|
||
<div className="bodyage-main">
|
||
<span className="bodyage-value">{data.value}</span>
|
||
<span className="bodyage-unit">岁</span>
|
||
</div>
|
||
<div className="bodyage-side">
|
||
<span className={`bodyage-delta ${delta < 0 ? 'good' : delta > 0 ? 'warn' : ''}`}>
|
||
{delta === 0
|
||
? '与实际年龄相当'
|
||
: `比实际年龄${delta < 0 ? '年轻' : '大'} ${Math.abs(delta)} 岁`}
|
||
</span>
|
||
<span className="bodyage-note">
|
||
实际 {data.chronologicalAge} 岁 · 看推算过程
|
||
</span>
|
||
</div>
|
||
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||
</button>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function HealthPage() {
|
||
const [days, setDays] = useState<HealthDay[]>([]);
|
||
const [bodyAge, setBodyAge] = useState<FitnessAge | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
try {
|
||
setDays(
|
||
await apiClient.getHealthSummary(
|
||
daysAgo(WINDOW_DAYS - 1), todayIso()
|
||
)
|
||
);
|
||
} catch (err: any) {
|
||
setError(errorMessage(err, '加载失败'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
load();
|
||
// Body age is a separate, optional read: an empty profile must not stop
|
||
// the rest of the page from rendering.
|
||
apiClient.getFitnessAge().then(setBodyAge).catch(() => setBodyAge(null));
|
||
}, []);
|
||
|
||
if (loading) {
|
||
return (
|
||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||
<Skeleton count={8} />
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||
<div className="screen-error">{error}</div>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
if (days.length === 0) {
|
||
return (
|
||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||
<div className="screen-empty">
|
||
<p>还没有任何健康数据。</p>
|
||
<Link href="/sync/" className="button button-fill button-round">
|
||
去同步 Garmin 数据
|
||
</Link>
|
||
</div>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
// The most recent day that actually recorded a given metric — showing "—"
|
||
// because today's sleep has not synced yet would hide data that exists.
|
||
const latest = (pick: (d: HealthDay) => number | null) => {
|
||
for (let i = days.length - 1; i >= 0; i--) {
|
||
const v = pick(days[i]);
|
||
if (v != null) return { value: v, date: days[i].date };
|
||
}
|
||
return { value: null as number | null, date: null as string | null };
|
||
};
|
||
|
||
return (
|
||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||
<BodyAge data={bodyAge} />
|
||
|
||
{FEATURES.ai && <AiPanel scope="health" title="AI 健康解读" />}
|
||
|
||
{SECTIONS.map((section) => (
|
||
<section className="sec" key={section.title}>
|
||
<h3 className="sec-title">{section.title}</h3>
|
||
<div className="mcard-grid">
|
||
{section.items.map((id) => {
|
||
const def = METRICS[id];
|
||
if (!def) return null;
|
||
const { value, date } = latest(def.pick);
|
||
const stale = date != null && date !== days[days.length - 1].date;
|
||
return (
|
||
<MetricCard
|
||
key={id}
|
||
metric={def.range}
|
||
label={def.label}
|
||
value={value}
|
||
unit={def.unit}
|
||
decimals={def.decimals}
|
||
trend={days.map(def.pick)}
|
||
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
|
||
onClick={() => f7.views.current.router.navigate(metricHref(id))}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
))}
|
||
|
||
<section className="sec">
|
||
<h3 className="sec-title">更多</h3>
|
||
<div className="set-rows">
|
||
{LINKS.map(([href, label, sub]) => (
|
||
<Link href={href} className="set-row" key={href}>
|
||
<span className="set-label">
|
||
{label}
|
||
<span className="set-sub">{sub}</span>
|
||
</span>
|
||
<span className="set-chevron" aria-hidden="true">›</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<p className="screen-disclaimer">
|
||
参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
|
||
</p>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
export default HealthPage;
|