feat(ui): 设置页重做 + 同步页简化 + 评分依据页
需求 2.5 / 2.6 / 2.7 / 2.8 / 5.1 / 5.2 / 5.3 设置页 - 个人资料:身高/体重/出生日期/性别,另显示算出的 BMI 与年龄 - 单位:公制 / 英制 - 同步:自动同步开关、频率、历史范围 - 改动即存,不放「保存」按钮——每项都是单值且效果直观, 而能保持脏状态的表单就是会悄悄丢编辑的表单 - 开关用原生 checkbox 只换绘制,键盘与读屏行为保持不变 /rating-basis/ 评分依据 - 11 项参考区间逐条列出边界与出处,包括「一般性参考,无权威标准」这种 诚实的答案。判断从哪来必须能查到。 同步页 - 首要按钮「同步最新数据」,等几秒直接返回结果 - 次要按钮「同步历史」,按设置里的历史范围后台跑 - 状态压缩成三格:上次同步 / 自动同步 / 已同步天数 - 删掉原来四段说明文字,它们把两个动作埋在了下面 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -182,3 +182,59 @@
|
|||||||
.status-item .value { text-align: left; }
|
.status-item .value { text-align: left; }
|
||||||
.sync-choices .btn { flex: 1; }
|
.sync-choices .btn { flex: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Simplified sync page ---------------------------------------------------- */
|
||||||
|
.sync-card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-card h3 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-card .field-hint { margin-bottom: 0.9rem; }
|
||||||
|
.sync-card .form-group { margin-bottom: 0.8rem; }
|
||||||
|
|
||||||
|
.sync-actions-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-fact {
|
||||||
|
background: var(--surface-1);
|
||||||
|
padding: 0.75rem 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.22rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-fact-label { font-size: 0.72rem; color: var(--text-muted); }
|
||||||
|
.sync-fact-value { font-size: 0.92rem; font-weight: 620; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.sync-lasterror {
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--status-critical);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|||||||
73
client/src/pages/RatingBasisPage.tsx
Normal file
73
client/src/pages/RatingBasisPage.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiClient, errorMessage, RatingBasis } from '../services/api';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import './BodyAge.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where every rating in the app comes from.
|
||||||
|
*
|
||||||
|
* A band that paints a number 偏低 is making a claim about the user's health.
|
||||||
|
* This screen is the receipt for that claim — one row per metric, with the
|
||||||
|
* source named, including the ones whose honest source is "general guidance,
|
||||||
|
* no authoritative standard".
|
||||||
|
*/
|
||||||
|
function RatingBasisPage() {
|
||||||
|
const [basis, setBasis] = useState<RatingBasis | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient
|
||||||
|
.getRatingBasis()
|
||||||
|
.then(setBasis)
|
||||||
|
.catch((err) => setError(errorMessage(err, '加载失败')))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <Screen title="评分依据" backLink><Skeleton count={5} /></Screen>;
|
||||||
|
if (error || !basis) {
|
||||||
|
return (
|
||||||
|
<Screen title="评分依据" backLink>
|
||||||
|
<div className="screen-error">{error || '加载失败'}</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="评分依据" backLink>
|
||||||
|
<p className="ba-summary">{basis.note}</p>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">各指标参考区间</h3>
|
||||||
|
<div className="ba-basis">
|
||||||
|
{basis.bands.map((b) => (
|
||||||
|
<div className="ba-basis-item" key={b.metric}>
|
||||||
|
<div className="ba-basis-name">{b.metric}</div>
|
||||||
|
<div className="ba-basis-detail rb-bands">{b.bands}</div>
|
||||||
|
<div className="ba-basis-source">来源:{b.source}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">{basis.fitnessAge.title}</h3>
|
||||||
|
<p className="ba-summary">{basis.fitnessAge.summary}</p>
|
||||||
|
<div className="ba-basis">
|
||||||
|
{basis.fitnessAge.steps.map((s) => (
|
||||||
|
<div className="ba-basis-item" key={s.name}>
|
||||||
|
<div className="ba-basis-name">{s.name}</div>
|
||||||
|
<div className="ba-basis-detail">{s.detail}</div>
|
||||||
|
<div className="ba-basis-source">来源:{s.source}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="screen-disclaimer">{basis.fitnessAge.caveat}</p>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RatingBasisPage;
|
||||||
@@ -135,3 +135,118 @@
|
|||||||
padding: 0.45rem 0.5rem;
|
padding: 0.45rem 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings rows ----------------------------------------------------------- */
|
||||||
|
.set-rows {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.78rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-row:last-child { border-bottom: none; }
|
||||||
|
.set-row:active:not(:disabled) { background: var(--surface-2); }
|
||||||
|
.set-row:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
.set-row-static { cursor: default; }
|
||||||
|
|
||||||
|
.set-label {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-sub { font-size: 0.73rem; color: var(--text-muted); font-weight: 400; }
|
||||||
|
|
||||||
|
.set-value {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-chevron { color: var(--text-muted); font-size: 1.1rem; line-height: 1; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.set-input {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: right;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-input:focus { outline: none; color: var(--accent); }
|
||||||
|
|
||||||
|
/* Switch. The native checkbox stays in the DOM (and keeps keyboard and
|
||||||
|
screen-reader behaviour); only its painting is replaced. */
|
||||||
|
.toggle { position: relative; flex-shrink: 0; width: 46px; height: 28px; }
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
transition: background 0.2s var(--ease), border-color 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
|
transition: transform 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-track {
|
||||||
|
background: var(--accent-solid);
|
||||||
|
border-color: var(--accent-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-track::after { transform: translateX(18px); }
|
||||||
|
.toggle input:focus-visible + .toggle-track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.rb-bands { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.toggle-track, .toggle-track::after { transition: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,90 +1,291 @@
|
|||||||
import Screen from '../components/Screen';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, f7 } from 'framework7-react';
|
import { Link, f7 } from 'framework7-react';
|
||||||
import { apiClient, ModelInfo } from '../services/api';
|
import {
|
||||||
|
apiClient, errorMessage, ModelInfo, SettingsOptions, UserSettings,
|
||||||
|
} from '../services/api';
|
||||||
import { FEATURES } from '../features';
|
import { FEATURES } from '../features';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
import './Settings.css';
|
import './Settings.css';
|
||||||
|
|
||||||
|
const SEX_LABEL: Record<string, string> = {
|
||||||
|
male: '男', female: '女', other: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
const UNIT_LABEL: Record<string, string> = {
|
||||||
|
metric: '公制(km / kg / °C)', imperial: '英制(mi / lb / °F)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalLabel = (minutes: number) =>
|
||||||
|
minutes < 60 ? `${minutes} 分钟`
|
||||||
|
: minutes === 1440 ? '每天一次'
|
||||||
|
: `${minutes / 60} 小时`;
|
||||||
|
|
||||||
|
const historyLabel = (days: number) =>
|
||||||
|
days === 0 ? '全部历史'
|
||||||
|
: days >= 365 ? `${days / 365} 年`
|
||||||
|
: `${days} 天`;
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
|
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||||
|
const [options, setOptions] = useState<SettingsOptions | null>(null);
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saved, setSaved] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!FEATURES.ai) {
|
Promise.all([apiClient.getSettings(), apiClient.getSettingsOptions()])
|
||||||
setLoading(false);
|
.then(([s, o]) => { setSettings(s); setOptions(o); })
|
||||||
return;
|
.catch((err) => setError(errorMessage(err, '加载设置失败')))
|
||||||
}
|
|
||||||
apiClient
|
|
||||||
.getModels()
|
|
||||||
.then(setModels)
|
|
||||||
.catch(() => setModels([]))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
|
if (FEATURES.ai) {
|
||||||
|
apiClient.getModels().then(setModels).catch(() => setModels([]));
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/* Saved on change rather than behind a 保存 button: every field here is a
|
||||||
|
single value with an obvious effect, and a form that can be left dirty
|
||||||
|
is a form that silently loses edits. */
|
||||||
|
const save = async (patch: Partial<UserSettings>) => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const next = await apiClient.saveSettings(patch);
|
||||||
|
setSettings(next);
|
||||||
|
setSaved('已保存');
|
||||||
|
window.setTimeout(() => setSaved(''), 1600);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(errorMessage(err, '保存失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (
|
||||||
|
title: string,
|
||||||
|
values: Array<string | number>,
|
||||||
|
label: (v: any) => string,
|
||||||
|
current: string | number,
|
||||||
|
onPick: (v: any) => void
|
||||||
|
) => {
|
||||||
|
f7.dialog.create({
|
||||||
|
title,
|
||||||
|
buttons: [
|
||||||
|
...values.map((v) => ({
|
||||||
|
text: label(v) + (v === current ? ' ✓' : ''),
|
||||||
|
onClick: () => onPick(v),
|
||||||
|
})),
|
||||||
|
{ text: '取消', color: 'gray' },
|
||||||
|
],
|
||||||
|
verticalButtons: true,
|
||||||
|
}).open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptNumber = (
|
||||||
|
title: string, unit: string, current: number | null,
|
||||||
|
onDone: (v: number | null) => void
|
||||||
|
) => {
|
||||||
|
f7.dialog.prompt(`${title}(${unit})`, title, (value) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
onDone(trimmed === '' ? null : Number(trimmed));
|
||||||
|
}, undefined, current == null ? '' : String(current));
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await apiClient.logout();
|
await apiClient.logout();
|
||||||
f7.views.current.router.navigate('/login/', { reloadAll: true });
|
f7.views.current.router.navigate('/login/', { reloadAll: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Screen title="设置"><Skeleton count={5} /></Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = settings;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title="设置">
|
<Screen title="设置">
|
||||||
<h2>设置</h2>
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
{saved && <div className="screen-ok">{saved}</div>}
|
||||||
|
|
||||||
{FEATURES.ai && (
|
{s && (
|
||||||
<section className="settings-section">
|
<>
|
||||||
<h3>AI 模型</h3>
|
<section className="sec">
|
||||||
<p className="settings-hint">
|
<h3 className="sec-title">个人资料</h3>
|
||||||
模型清单与优先级由后端 <code>backend/.env</code> 决定。
|
<p className="settings-hint">
|
||||||
填入对应厂商的密钥后,模型会自动变为可用;
|
身高、体重、年龄与性别用于计算 BMI、基础代谢与身体年龄。
|
||||||
<code>AI_MODEL_CHAIN</code> 控制自动模式下的尝试顺序。
|
留空不影响其他功能,只是这几项会显示为「—」。
|
||||||
</p>
|
</p>
|
||||||
|
<div className="set-rows">
|
||||||
|
<button
|
||||||
|
className="set-row"
|
||||||
|
onClick={() => promptNumber('身高', 'cm', s.heightCm,
|
||||||
|
(v) => save({ heightCm: v }))}
|
||||||
|
>
|
||||||
|
<span className="set-label">身高</span>
|
||||||
|
<span className="set-value">
|
||||||
|
{s.heightCm == null ? '未设置' : `${s.heightCm} cm`}
|
||||||
|
</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{loading ? (
|
<button
|
||||||
<p className="screen-note">加载中…</p>
|
className="set-row"
|
||||||
) : models.length === 0 ? (
|
onClick={() => promptNumber('体重', 'kg', s.weightKg,
|
||||||
<p className="screen-note">无法获取模型列表。</p>
|
(v) => save({ weightKg: v }))}
|
||||||
) : (
|
>
|
||||||
<table className="model-table">
|
<span className="set-label">体重</span>
|
||||||
<thead>
|
<span className="set-value">
|
||||||
<tr>
|
{s.weightKg == null ? '未设置' : `${s.weightKg} kg`}
|
||||||
<th>ID</th>
|
</span>
|
||||||
<th>模型</th>
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
<th>上下文</th>
|
</button>
|
||||||
<th>状态</th>
|
|
||||||
</tr>
|
<label className="set-row">
|
||||||
</thead>
|
<span className="set-label">出生日期</span>
|
||||||
<tbody>
|
<input
|
||||||
{models.map((m) => (
|
className="set-input"
|
||||||
<tr key={m.id}>
|
type="date"
|
||||||
<td>
|
value={s.birthDate ?? ''}
|
||||||
<code>{m.id}</code>
|
max={new Date().toISOString().slice(0, 10)}
|
||||||
{m.default && <span className="badge-default">默认</span>}
|
onChange={(e) => save({ birthDate: e.target.value || null })}
|
||||||
</td>
|
/>
|
||||||
<td className="model-name">{m.model}</td>
|
</label>
|
||||||
<td>{(m.contextWindow / 1000).toLocaleString()}k</td>
|
|
||||||
<td>
|
<button
|
||||||
<span className={`badge ${m.configured ? 'ok' : 'off'}`}>
|
className="set-row"
|
||||||
{m.configured ? '已配置' : '缺少密钥'}
|
onClick={() => options && pick('性别', options.sexes,
|
||||||
</span>
|
(v) => SEX_LABEL[v] ?? v, s.sex ?? '',
|
||||||
</td>
|
(v) => save({ sex: v }))}
|
||||||
</tr>
|
>
|
||||||
))}
|
<span className="set-label">性别</span>
|
||||||
</tbody>
|
<span className="set-value">
|
||||||
</table>
|
{s.sex ? SEX_LABEL[s.sex] : '未设置'}
|
||||||
)}
|
</span>
|
||||||
</section>
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="set-row set-row-static">
|
||||||
|
<span className="set-label">BMI</span>
|
||||||
|
<span className="set-value">
|
||||||
|
{s.bmi ?? '—'}
|
||||||
|
{s.age != null && <span className="set-sub"> · {s.age} 岁</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">单位</h3>
|
||||||
|
<div className="set-rows">
|
||||||
|
<button
|
||||||
|
className="set-row"
|
||||||
|
onClick={() => options && pick('单位', options.units,
|
||||||
|
(v) => UNIT_LABEL[v] ?? v, s.units, (v) => save({ units: v }))}
|
||||||
|
>
|
||||||
|
<span className="set-label">显示单位</span>
|
||||||
|
<span className="set-value">{UNIT_LABEL[s.units]}</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">同步</h3>
|
||||||
|
<div className="set-rows">
|
||||||
|
<label className="set-row">
|
||||||
|
<span className="set-label">
|
||||||
|
自动同步
|
||||||
|
<span className="set-sub">后台按下面的频率拉取最新数据</span>
|
||||||
|
</span>
|
||||||
|
<span className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={s.autoSync}
|
||||||
|
onChange={(e) => save({ autoSync: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="set-row"
|
||||||
|
disabled={!s.autoSync}
|
||||||
|
onClick={() => options && pick('同步频率', options.autoSyncMinutes,
|
||||||
|
intervalLabel, s.autoSyncMinutes,
|
||||||
|
(v) => save({ autoSyncMinutes: v }))}
|
||||||
|
>
|
||||||
|
<span className="set-label">同步频率</span>
|
||||||
|
<span className="set-value">{intervalLabel(s.autoSyncMinutes)}</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="set-row"
|
||||||
|
onClick={() => options && pick('历史范围', options.historyDays,
|
||||||
|
historyLabel, s.historyDays,
|
||||||
|
(v) => save({ historyDays: v }))}
|
||||||
|
>
|
||||||
|
<span className="set-label">
|
||||||
|
历史范围
|
||||||
|
<span className="set-sub">「全量同步」往回拉多久的数据</span>
|
||||||
|
</span>
|
||||||
|
<span className="set-value">{historyLabel(s.historyDays)}</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link href="/sync/" className="set-row">
|
||||||
|
<span className="set-label">数据同步</span>
|
||||||
|
<span className="set-value">绑定账号 · 手动同步</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="sec">
|
||||||
<h3>数据</h3>
|
<h3 className="sec-title">评分依据</h3>
|
||||||
<p className="settings-hint">从 Garmin Connect 拉取健康数据,或重新绑定账号。</p>
|
<p className="settings-hint">
|
||||||
<Link href="/sync/" className="button button-fill button-round">
|
应用里每个「偏低 / 正常 / 达标」的判断都基于公开参考值,
|
||||||
前往数据同步
|
不是模型现编的。AI 只负责解读结果,不参与设定任何阈值。
|
||||||
</Link>
|
</p>
|
||||||
|
<div className="set-rows">
|
||||||
|
<Link href="/rating-basis/" className="set-row">
|
||||||
|
<span className="set-label">查看全部阈值与出处</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/body-age/" className="set-row">
|
||||||
|
<span className="set-label">身体年龄怎么算的</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="settings-section">
|
{FEATURES.ai && models.length > 0 && (
|
||||||
<h3>数据与隐私</h3>
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">AI 模型</h3>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th><th scope="col">上下文</th><th scope="col">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{models.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<th scope="row"><code>{m.id}</code></th>
|
||||||
|
<td>{(m.contextWindow / 1000).toLocaleString()}k</td>
|
||||||
|
<td>{m.configured ? '已配置' : '缺少密钥'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">数据与隐私</h3>
|
||||||
<ul className="settings-list">
|
<ul className="settings-list">
|
||||||
<li>健康数据保存在自建数据库中,不上传第三方服务。</li>
|
<li>健康数据保存在自建数据库中,不上传第三方服务。</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -92,15 +293,11 @@ function SettingsPage() {
|
|||||||
届时在「同步」页重新绑定一次即可。
|
届时在「同步」页重新绑定一次即可。
|
||||||
</li>
|
</li>
|
||||||
<li>本站登录密码以 PBKDF2 加盐哈希存储,无法还原。</li>
|
<li>本站登录密码以 PBKDF2 加盐哈希存储,无法还原。</li>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="sec">
|
||||||
<h3>账户</h3>
|
<button className="btn btn-danger" onClick={handleLogout}>退出登录</button>
|
||||||
<button className="btn btn-danger" onClick={handleLogout}>
|
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import Screen from '../components/Screen';
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus,
|
apiClient, AutoSyncStatus, errorMessage, GarminLoginStatus, parseUtc,
|
||||||
|
SyncStatus, UserSettings,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
import './DataSync.css';
|
import './DataSync.css';
|
||||||
|
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
|
|
||||||
|
const when = (value: string | null | undefined) => {
|
||||||
|
const d = parseUtc(value);
|
||||||
|
if (!d) return '从未';
|
||||||
|
const mins = Math.round((Date.now() - d.getTime()) / 60000);
|
||||||
|
if (mins < 1) return '刚刚';
|
||||||
|
if (mins < 60) return `${mins} 分钟前`;
|
||||||
|
if (mins < 60 * 24) return `${Math.round(mins / 60)} 小时前`;
|
||||||
|
return d.toLocaleDateString('zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
|
const historyLabel = (days: number) =>
|
||||||
|
days === 0 ? '全部历史' : days >= 365 ? `${days / 365} 年` : `${days} 天`;
|
||||||
|
|
||||||
function SyncPage() {
|
function SyncPage() {
|
||||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||||
|
const [auto, setAuto] = useState<AutoSyncStatus | null>(null);
|
||||||
|
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||||
const [hasToken, setHasToken] = useState<boolean | null>(null);
|
const [hasToken, setHasToken] = useState<boolean | null>(null);
|
||||||
|
|
||||||
// Garmin login (only needed until a token is stored)
|
// Garmin login (only needed until a token is stored)
|
||||||
@@ -31,22 +47,28 @@ function SyncPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadSyncStatus = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSyncStatus(await apiClient.getGarminSyncStatus());
|
const [s, a] = await Promise.all([
|
||||||
} catch (err) {
|
apiClient.getGarminSyncStatus(),
|
||||||
|
apiClient.getAutoSyncStatus().catch(() => null),
|
||||||
|
]);
|
||||||
|
setSyncStatus(s);
|
||||||
|
if (a) setAuto(a);
|
||||||
|
return s;
|
||||||
|
} catch {
|
||||||
// A failed status poll should not blank the page.
|
// A failed status poll should not blank the page.
|
||||||
console.error('Failed to load sync status:', err);
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A backfill outlives the page, so a reload must pick the progress back up.
|
// A backfill outlives the page, so a reload must pick the progress back up.
|
||||||
apiClient.getGarminSyncStatus().then((s) => {
|
refresh().then((s) => {
|
||||||
setSyncStatus(s);
|
if (s?.status === 'syncing') beginSyncPolling();
|
||||||
if (s.status === 'syncing') beginSyncPolling();
|
});
|
||||||
}).catch(() => undefined);
|
|
||||||
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
|
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
|
||||||
|
apiClient.getSettings().then(setSettings).catch(() => setSettings(null));
|
||||||
return stopPolling;
|
return stopPolling;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -88,7 +110,7 @@ function SyncPage() {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setHasToken(true);
|
setHasToken(true);
|
||||||
setMessage('Garmin 登录成功,之后同步不再需要密码或验证码。');
|
setMessage('绑定成功,之后同步不再需要密码或验证码。');
|
||||||
} else if (status === 'failed') {
|
} else if (status === 'failed') {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
setSession(null);
|
setSession(null);
|
||||||
@@ -140,13 +162,32 @@ function SyncPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- sync ---------------------------------------------------------------
|
// --- sync ---------------------------------------------------------------
|
||||||
const handleSync = async (days: number) => {
|
|
||||||
|
/** The last couple of days, awaited inline — seconds, not minutes. */
|
||||||
|
const syncLatest = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.syncGarminData(days);
|
const result = await apiClient.syncLatest(2);
|
||||||
await loadSyncStatus();
|
await refresh();
|
||||||
|
setMessage(result.message || `已更新最近 ${result.recordsSynced ?? 2} 天`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(errorMessage(err, '同步失败'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The configured history window, in the background with progress. */
|
||||||
|
const syncHistory = async () => {
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 0 means "everything"; the backend caps it at what Garmin will serve.
|
||||||
|
await apiClient.syncGarminData(settings?.historyDays || 3650);
|
||||||
|
await refresh();
|
||||||
beginSyncPolling();
|
beginSyncPolling();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(errorMessage(err, '同步失败'));
|
setError(errorMessage(err, '同步失败'));
|
||||||
@@ -155,29 +196,19 @@ function SyncPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The sync runs in the background, so the page follows it by polling
|
// The backfill runs in the background, so the page follows it by polling
|
||||||
// rather than by holding a request open for the whole backfill.
|
// rather than by holding a request open for the whole thing.
|
||||||
const beginSyncPolling = () => {
|
const beginSyncPolling = () => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollRef.current = window.setInterval(async () => {
|
pollRef.current = window.setInterval(async () => {
|
||||||
try {
|
const s = await refresh();
|
||||||
const s = await apiClient.getGarminSyncStatus();
|
if (!s) { stopPolling(); return; }
|
||||||
setSyncStatus(s);
|
if (s.status !== 'syncing') {
|
||||||
if (s.status !== 'syncing') {
|
|
||||||
stopPolling();
|
|
||||||
if (s.status === 'error') setError(s.lastError || '同步失败');
|
|
||||||
else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
if (s.status === 'error') setError(s.lastError || '同步失败');
|
||||||
|
else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, POLL_MS);
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
|
||||||
idle: '就绪',
|
|
||||||
syncing: '正在同步…',
|
|
||||||
error: '上次同步失败',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncing = syncStatus?.status === 'syncing';
|
const syncing = syncStatus?.status === 'syncing';
|
||||||
@@ -189,176 +220,150 @@ function SyncPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title="数据同步" backLink>
|
<Screen title="数据同步" backLink>
|
||||||
<h2>数据同步</h2>
|
{error && <div className="screen-error">{error}</div>}
|
||||||
<p className="subtitle">从 Garmin Connect 拉取最近 7 天的健康数据</p>
|
{message && <div className="screen-ok">{message}</div>}
|
||||||
|
|
||||||
<div className="sync-container">
|
{/* Step 1 — link the Garmin account, once. */}
|
||||||
<section className="status-card">
|
{hasToken === false && !session && (
|
||||||
<h3>同步状态</h3>
|
<form className="sync-card" onSubmit={startLogin}>
|
||||||
{syncStatus ? (
|
<h3>绑定 Garmin 账号</h3>
|
||||||
<div className="status-info">
|
<p className="field-hint">
|
||||||
<div className="status-item">
|
只需一次。保存的是授权令牌(约一年有效),密码不会被存储。
|
||||||
<span className="label">状态</span>
|
开启了两步验证的话,下一步会让你填验证码。
|
||||||
<span className={`value status-${syncStatus.status}`}>
|
</p>
|
||||||
{statusLabel[syncStatus.status] ?? syncStatus.status}
|
<div className="form-group">
|
||||||
</span>
|
<input
|
||||||
|
id="garmin-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Garmin 密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
|
||||||
|
{loading ? '正在连接…' : '绑定'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2 — the two-factor code. */}
|
||||||
|
{session && (
|
||||||
|
<section className="sync-card mfa-card">
|
||||||
|
{loginState === 'starting' && !codeSubmitted && (
|
||||||
|
<p className="screen-note">正在连接 Garmin…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{awaitingCode && (
|
||||||
|
<form onSubmit={submitCode}>
|
||||||
|
<h3>输入验证码</h3>
|
||||||
|
<p className="field-hint">Garmin 已向你的手机或邮箱发送了 6 位验证码。</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
id="mfa-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={10}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="6 位数字"
|
||||||
|
className="code-input"
|
||||||
|
disabled={loading || codeSubmitted}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-item">
|
<div className="mfa-buttons">
|
||||||
<span className="label">最后同步</span>
|
<button
|
||||||
<span className="value">
|
type="submit"
|
||||||
{parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
|
className="btn btn-primary"
|
||||||
?? '从未同步'}
|
disabled={loading || codeSubmitted || !code.trim()}
|
||||||
</span>
|
>
|
||||||
|
{codeSubmitted ? '正在验证…' : '提交'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-item">
|
</form>
|
||||||
<span className="label">已同步天数</span>
|
)}
|
||||||
<span className="value">{syncStatus.recordsSynced}</span>
|
|
||||||
</div>
|
{loginState === 'finishing' && (
|
||||||
{syncStatus.lastError && (
|
<p className="screen-note">验证通过,正在完成登录…</p>
|
||||||
<div className="status-item error">
|
|
||||||
<span className="label">错误</span>
|
|
||||||
<span className="value">{syncStatus.lastError}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="screen-note">加载中…</p>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Step 1 — link the Garmin account, once. */}
|
{/* Step 3 — the whole page, once linked. Status on top, two actions
|
||||||
{hasToken === false && !session && (
|
under it; the old version buried both under four paragraphs. */}
|
||||||
<form className="sync-actions" onSubmit={startLogin}>
|
{hasToken === true && (
|
||||||
<div className="form-group">
|
<>
|
||||||
<label htmlFor="garmin-password">Garmin 密码</label>
|
{syncing ? (
|
||||||
<input
|
<section className="sync-card">
|
||||||
id="garmin-password"
|
<div className="progress-head">
|
||||||
type="password"
|
<span>正在同步…</span>
|
||||||
value={password}
|
<span className="progress-count">{current} / {total} 天</span>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
</div>
|
||||||
placeholder="••••••••"
|
<div
|
||||||
autoComplete="current-password"
|
className="progress-bar"
|
||||||
disabled={loading}
|
role="progressbar"
|
||||||
/>
|
aria-valuenow={pct}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
<p className="field-hint">
|
<p className="field-hint">
|
||||||
只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年),
|
在后台运行,可以离开本页
|
||||||
密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。
|
{total > 60 ? `,预计还需 ${Math.ceil(((total - current) * 3) / 60)} 分钟` : ''}。
|
||||||
</p>
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="sync-actions-row">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-large"
|
||||||
|
onClick={syncLatest}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{loading ? '同步中…' : '同步最新数据'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-plain" onClick={syncHistory} disabled={busy}>
|
||||||
|
同步历史({historyLabel(settings?.historyDays ?? 365)})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
|
)}
|
||||||
{loading ? '正在连接…' : '绑定 Garmin 账号'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2 — the two-factor code. */}
|
<div className="sync-facts">
|
||||||
{session && (
|
<div className="sync-fact">
|
||||||
<section className="status-card mfa-card">
|
<span className="sync-fact-label">上次同步</span>
|
||||||
{loginState === 'starting' && !codeSubmitted && (
|
<span className="sync-fact-value">
|
||||||
<p className="screen-note">正在连接 Garmin…</p>
|
{when(syncStatus?.lastSyncTime)}
|
||||||
)}
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sync-fact">
|
||||||
|
<span className="sync-fact-label">自动同步</span>
|
||||||
|
<span className="sync-fact-value">
|
||||||
|
{auto?.account?.autoSync === false
|
||||||
|
? '已关闭'
|
||||||
|
: auto?.account
|
||||||
|
? `每 ${auto.account.intervalMinutes >= 60
|
||||||
|
? `${auto.account.intervalMinutes / 60} 小时`
|
||||||
|
: `${auto.account.intervalMinutes} 分钟`}`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sync-fact">
|
||||||
|
<span className="sync-fact-label">已同步天数</span>
|
||||||
|
<span className="sync-fact-value">{syncStatus?.recordsSynced ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{awaitingCode && (
|
{syncStatus?.lastError && !syncing && (
|
||||||
<form onSubmit={submitCode}>
|
<p className="sync-lasterror">上次错误:{syncStatus.lastError}</p>
|
||||||
<h3>输入验证码</h3>
|
)}
|
||||||
<p className="field-hint" style={{ marginBottom: '1rem' }}>
|
</>
|
||||||
Garmin 已向你的手机或邮箱发送了 6 位验证码,请在下方填写。
|
)}
|
||||||
</p>
|
|
||||||
<div className="form-group">
|
|
||||||
<input
|
|
||||||
id="mfa-code"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
maxLength={10}
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
placeholder="6 位数字"
|
|
||||||
className="code-input"
|
|
||||||
disabled={loading || codeSubmitted}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mfa-buttons">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={loading || codeSubmitted || !code.trim()}
|
|
||||||
>
|
|
||||||
{codeSubmitted ? '正在验证…' : '提交验证码'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loginState === 'finishing' && (
|
|
||||||
<p className="screen-note">验证通过,正在完成登录…</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3 — sync, once linked. */}
|
|
||||||
{hasToken === true && (
|
|
||||||
<section className="status-card">
|
|
||||||
<h3>拉取数据</h3>
|
|
||||||
<p className="field-hint" style={{ marginBottom: '0.9rem' }}>
|
|
||||||
已绑定 Garmin 账号,同步无需密码。首次建议回补一段历史,
|
|
||||||
之后日常只需拉最近 7 天。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{syncing && total > 0 ? (
|
|
||||||
<div className="progress-block">
|
|
||||||
<div className="progress-head">
|
|
||||||
<span>正在同步…</span>
|
|
||||||
<span className="progress-count">{current} / {total} 天</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="progress-bar"
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuenow={pct}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={100}
|
|
||||||
>
|
|
||||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<p className="field-hint">
|
|
||||||
在后台运行,可以离开本页。约每天 3 秒,
|
|
||||||
{total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="sync-choices">
|
|
||||||
{[7, 30, 90, 365].map((d) => (
|
|
||||||
<button
|
|
||||||
key={d}
|
|
||||||
onClick={() => handleSync(d)}
|
|
||||||
className={`btn ${d === 7 ? 'btn-primary' : 'btn-plain'}`}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{d === 365 ? '回补一年' : `最近 ${d} 天`}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="screen-error">{error}</div>}
|
|
||||||
{message && <div className="screen-ok">{message}</div>}
|
|
||||||
|
|
||||||
<section className="info-box">
|
|
||||||
<h4>关于数据同步</h4>
|
|
||||||
<ul>
|
|
||||||
<li>每次同步获取最近 7 天的每日汇总与运动记录</li>
|
|
||||||
<li>同一天重复同步会更新原有记录,不会产生重复数据</li>
|
|
||||||
<li>数据保存在本机数据库,不经过第三方服务</li>
|
|
||||||
<li>Garmin 授权令牌约一年过期,届时重新绑定一次即可</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import TrendsPage from './pages/TrendsPage';
|
|||||||
import MetricDetailPage from './pages/MetricDetailPage';
|
import MetricDetailPage from './pages/MetricDetailPage';
|
||||||
import ActivityDetailPage from './pages/ActivityDetailPage';
|
import ActivityDetailPage from './pages/ActivityDetailPage';
|
||||||
import BodyAgePage from './pages/BodyAgePage';
|
import BodyAgePage from './pages/BodyAgePage';
|
||||||
|
import RatingBasisPage from './pages/RatingBasisPage';
|
||||||
import ExercisePage from './pages/ExercisePage';
|
import ExercisePage from './pages/ExercisePage';
|
||||||
import SleepPage from './pages/SleepPage';
|
import SleepPage from './pages/SleepPage';
|
||||||
import SyncPage from './pages/SyncPage';
|
import SyncPage from './pages/SyncPage';
|
||||||
@@ -32,6 +33,7 @@ const routes: Router.RouteParameters[] = [
|
|||||||
{ path: '/metric/:id/', component: MetricDetailPage },
|
{ path: '/metric/:id/', component: MetricDetailPage },
|
||||||
{ path: '/activity/:id/', component: ActivityDetailPage },
|
{ path: '/activity/:id/', component: ActivityDetailPage },
|
||||||
{ path: '/body-age/', component: BodyAgePage },
|
{ path: '/body-age/', component: BodyAgePage },
|
||||||
|
{ path: '/rating-basis/', component: RatingBasisPage },
|
||||||
{ path: '/sync/', component: SyncPage },
|
{ path: '/sync/', component: SyncPage },
|
||||||
{ path: '/settings/', component: SettingsPage },
|
{ path: '/settings/', component: SettingsPage },
|
||||||
{ path: '/login/', component: LoginPage },
|
{ path: '/login/', component: LoginPage },
|
||||||
|
|||||||
@@ -26,10 +26,10 @@
|
|||||||
| 2.2 | 验证码在前端输入(人不在电脑前) | Web MFA:后台线程阻塞在 `prompt_mfa`,验证码经数据库跨 worker 交接 | ✅ |
|
| 2.2 | 验证码在前端输入(人不在电脑前) | Web MFA:后台线程阻塞在 `prompt_mfa`,验证码经数据库跨 worker 交接 | ✅ |
|
||||||
| 2.3 | 同步改为用户手动触发,不自动跑 | 同步是界面动作 | ✅ |
|
| 2.3 | 同步改为用户手动触发,不自动跑 | 同步是界面动作 | ✅ |
|
||||||
| 2.4 | 每小时后台同步最新数据 | `job_locks` 抢占,多 worker 只跑一次 | ✅ |
|
| 2.4 | 每小时后台同步最新数据 | `job_locks` 抢占,多 worker 只跑一次 | ✅ |
|
||||||
| 2.5 | 「同步最新数据」按钮 | `POST /api/garmin/sync-latest`,窗口 1–7 天,同步返回 | ✅ 接口完成,🚧 界面按钮 |
|
| 2.5 | 「同步最新数据」按钮 | 同步页首要按钮,等待即返回结果 | ✅ |
|
||||||
| 2.6 | 同步频率可设置 | 30 分 / 1 时 / 3 时 / 6 时 / 12 时 / 1 天 | ✅ 接口完成,🚧 界面 |
|
| 2.6 | 同步频率可设置 | 设置页选择,调度器按各账号自己的频率判断是否该同步 | ✅ |
|
||||||
| 2.7 | 同步历史范围可设置(多久以前 / 全部) | 7/30/90/180/365/730 天,或全部 | ✅ 接口完成,🚧 界面 |
|
| 2.7 | 同步历史范围可设置(多久以前 / 全部) | 设置页选择,同步页「同步历史」按此范围拉取 | ✅ |
|
||||||
| 2.8 | 同步界面简化设计 | 去掉冗长说明,状态 + 两个动作为主 | 📋 |
|
| 2.8 | 同步界面简化设计 | 两个按钮 + 三格状态,去掉原来四段说明文字 | ✅ |
|
||||||
|
|
||||||
## 三、界面框架
|
## 三、界面框架
|
||||||
|
|
||||||
@@ -39,9 +39,9 @@
|
|||||||
| 3.2 | Tab:今日 / 健康 / 趋势 / 运动 / 设置 | 每日是趋势的子页 | ✅ |
|
| 3.2 | Tab:今日 / 健康 / 趋势 / 运动 / 设置 | 每日是趋势的子页 | ✅ |
|
||||||
| 3.3 | 首页改三圆环:步数 / 睡眠 / HRV | 并列三环,满环=进入参考区间 | ✅ |
|
| 3.3 | 首页改三圆环:步数 / 睡眠 / HRV | 并列三环,满环=进入参考区间 | ✅ |
|
||||||
| 3.4 | 交互流畅、有动画 | 数字滚动、入场揭示、`prefers-reduced-motion` | ✅ |
|
| 3.4 | 交互流畅、有动画 | 数字滚动、入场揭示、`prefers-reduced-motion` | ✅ |
|
||||||
| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 |
|
|
||||||
| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip)渲染出来的,已移除 | ✅ |
|
| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip)渲染出来的,已移除 | ✅ |
|
||||||
| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走;每日入口同样移进趋势页内容 | ✅ |
|
| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走;每日入口同样移进趋势页内容 | ✅ |
|
||||||
|
| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 |
|
||||||
|
|
||||||
## 四、数据展示
|
## 四、数据展示
|
||||||
|
|
||||||
@@ -61,9 +61,9 @@
|
|||||||
|
|
||||||
| # | 需求 | 理解 | 状态 |
|
| # | 需求 | 理解 | 状态 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 5.1 | 设置项太少:身高/体重/年龄/性别 | `user_settings` 表 | ✅ 接口完成,🚧 界面 |
|
| 5.1 | 设置项太少:身高/体重/年龄/性别 | 设置页个人资料分组,改动即存,另显示 BMI 与年龄 | ✅ |
|
||||||
| 5.2 | 单位设置 | 公制 / 英制 | ✅ 接口完成,🚧 界面 |
|
| 5.2 | 单位设置 | 公制 / 英制,已存储;各页按此显示待接入 | ✅ 设置完成,🚧 全局套用 |
|
||||||
| 5.3 | 评分依据要在设置说明中显示 | `GET /api/settings/rating-basis`,逐条列出每个参考区间的出处 | ✅ 接口完成,🚧 界面 |
|
| 5.3 | 评分依据要在设置说明中显示 | 设置 → 评分依据 `/rating-basis/`,11 项区间逐条列出处;指标详情页也各带一份 | ✅ |
|
||||||
|
|
||||||
## 六、AI
|
## 六、AI
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user