原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外 的 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>
108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Link } from 'framework7-react';
|
|
import { apiClient, Challenge, errorMessage } from '../services/api';
|
|
import Screen from '../components/Screen';
|
|
import AiPanel from '../components/AiPanel';
|
|
import { FEATURES } from '../features';
|
|
import Skeleton from '../components/Skeleton';
|
|
import './Challenges.css';
|
|
|
|
const KIND_LABEL: Record<string, string> = {
|
|
badge: '徽章挑战',
|
|
adhoc: '好友挑战',
|
|
available: '可参加',
|
|
inprogress: '进行中',
|
|
};
|
|
|
|
/** Percentage complete, if the payload carries a target and a total. */
|
|
function progressOf(c: Challenge): number | null {
|
|
const p = c.payload || {};
|
|
const target = p.badgeTargetValue ?? p.targetValue ?? p.challengeTargetValue;
|
|
const current = p.userRankValue ?? p.badgeProgressValue ?? p.currentValue;
|
|
if (!target || current == null) return null;
|
|
return Math.min(100, Math.round((Number(current) / Number(target)) * 100));
|
|
}
|
|
|
|
function ChallengesPage() {
|
|
const [rows, setRows] = useState<Challenge[]>([]);
|
|
const [kind, setKind] = useState<string>('all');
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
|
|
useEffect(() => {
|
|
apiClient
|
|
.getChallenges()
|
|
.then(setRows)
|
|
.catch((err) => setError(errorMessage(err, '加载失败')))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const kinds = useMemo(
|
|
() => ['all', ...Array.from(new Set(rows.map((r) => r.kind)))],
|
|
[rows]
|
|
);
|
|
const shown = kind === 'all' ? rows : rows.filter((r) => r.kind === kind);
|
|
|
|
if (loading) return <Screen title="挑战赛" backLink><Skeleton count={5} /></Screen>;
|
|
|
|
return (
|
|
<Screen title="挑战赛" backLink subtitle={`共 ${rows.length} 项`}>
|
|
{error && <div className="screen-error">{error}</div>}
|
|
|
|
{rows.length === 0 ? (
|
|
<div className="screen-empty">
|
|
<p>还没有挑战赛数据。</p>
|
|
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{FEATURES.ai && <AiPanel scope="challenges" title="AI 挑战赛解读" />}
|
|
|
|
{kinds.length > 2 && (
|
|
<div className="metric-tabs">
|
|
{kinds.map((k) => (
|
|
<button
|
|
key={k}
|
|
className={`metric-tab ${k === kind ? 'active' : ''}`}
|
|
onClick={() => setKind(k)}
|
|
>
|
|
{k === 'all' ? '全部' : KIND_LABEL[k] ?? k}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="chal-list">
|
|
{shown.map((c, i) => {
|
|
const pct = progressOf(c);
|
|
return (
|
|
<div className="chal" key={`${c.uuid}-${i}`}>
|
|
<div className="chal-head">
|
|
<span className="chal-name">{c.name || '未命名挑战'}</span>
|
|
<span className="chal-kind">{KIND_LABEL[c.kind] ?? c.kind}</span>
|
|
</div>
|
|
{(c.startDate || c.endDate) && (
|
|
<div className="chal-dates">
|
|
{c.startDate} {c.endDate ? `→ ${c.endDate}` : ''}
|
|
</div>
|
|
)}
|
|
{pct != null && (
|
|
<>
|
|
<div className="chal-bar">
|
|
<div className="chal-fill" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<div className="chal-pct">{pct}%</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
export default ChallengesPage;
|