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; }
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 { 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 Screen from '../components/Screen';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
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() {
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [options, setOptions] = useState<SettingsOptions | null>(null);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [saved, setSaved] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!FEATURES.ai) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
apiClient
|
||||
.getModels()
|
||||
.then(setModels)
|
||||
.catch(() => setModels([]))
|
||||
Promise.all([apiClient.getSettings(), apiClient.getSettingsOptions()])
|
||||
.then(([s, o]) => { setSettings(s); setOptions(o); })
|
||||
.catch((err) => setError(errorMessage(err, '加载设置失败')))
|
||||
.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 () => {
|
||||
await apiClient.logout();
|
||||
f7.views.current.router.navigate('/login/', { reloadAll: true });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Screen title="设置"><Skeleton count={5} /></Screen>;
|
||||
}
|
||||
|
||||
const s = settings;
|
||||
|
||||
return (
|
||||
<Screen title="设置">
|
||||
<h2>设置</h2>
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
{saved && <div className="screen-ok">{saved}</div>}
|
||||
|
||||
{FEATURES.ai && (
|
||||
<section className="settings-section">
|
||||
<h3>AI 模型</h3>
|
||||
<p className="settings-hint">
|
||||
模型清单与优先级由后端 <code>backend/.env</code> 决定。
|
||||
填入对应厂商的密钥后,模型会自动变为可用;
|
||||
<code>AI_MODEL_CHAIN</code> 控制自动模式下的尝试顺序。
|
||||
</p>
|
||||
{s && (
|
||||
<>
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">个人资料</h3>
|
||||
<p className="settings-hint">
|
||||
身高、体重、年龄与性别用于计算 BMI、基础代谢与身体年龄。
|
||||
留空不影响其他功能,只是这几项会显示为「—」。
|
||||
</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 ? (
|
||||
<p className="screen-note">加载中…</p>
|
||||
) : models.length === 0 ? (
|
||||
<p className="screen-note">无法获取模型列表。</p>
|
||||
) : (
|
||||
<table className="model-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>模型</th>
|
||||
<th>上下文</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<code>{m.id}</code>
|
||||
{m.default && <span className="badge-default">默认</span>}
|
||||
</td>
|
||||
<td className="model-name">{m.model}</td>
|
||||
<td>{(m.contextWindow / 1000).toLocaleString()}k</td>
|
||||
<td>
|
||||
<span className={`badge ${m.configured ? 'ok' : 'off'}`}>
|
||||
{m.configured ? '已配置' : '缺少密钥'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
<button
|
||||
className="set-row"
|
||||
onClick={() => promptNumber('体重', 'kg', s.weightKg,
|
||||
(v) => save({ weightKg: v }))}
|
||||
>
|
||||
<span className="set-label">体重</span>
|
||||
<span className="set-value">
|
||||
{s.weightKg == null ? '未设置' : `${s.weightKg} kg`}
|
||||
</span>
|
||||
<span className="set-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
|
||||
<label className="set-row">
|
||||
<span className="set-label">出生日期</span>
|
||||
<input
|
||||
className="set-input"
|
||||
type="date"
|
||||
value={s.birthDate ?? ''}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(e) => save({ birthDate: e.target.value || null })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="set-row"
|
||||
onClick={() => options && pick('性别', options.sexes,
|
||||
(v) => SEX_LABEL[v] ?? v, s.sex ?? '',
|
||||
(v) => save({ sex: v }))}
|
||||
>
|
||||
<span className="set-label">性别</span>
|
||||
<span className="set-value">
|
||||
{s.sex ? SEX_LABEL[s.sex] : '未设置'}
|
||||
</span>
|
||||
<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">
|
||||
<h3>数据</h3>
|
||||
<p className="settings-hint">从 Garmin Connect 拉取健康数据,或重新绑定账号。</p>
|
||||
<Link href="/sync/" className="button button-fill button-round">
|
||||
前往数据同步
|
||||
</Link>
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">评分依据</h3>
|
||||
<p className="settings-hint">
|
||||
应用里每个「偏低 / 正常 / 达标」的判断都基于公开参考值,
|
||||
不是模型现编的。AI 只负责解读结果,不参与设定任何阈值。
|
||||
</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 className="settings-section">
|
||||
<h3>数据与隐私</h3>
|
||||
{FEATURES.ai && models.length > 0 && (
|
||||
<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">
|
||||
<li>健康数据保存在自建数据库中,不上传第三方服务。</li>
|
||||
<li>
|
||||
@@ -92,15 +293,11 @@ function SettingsPage() {
|
||||
届时在「同步」页重新绑定一次即可。
|
||||
</li>
|
||||
<li>本站登录密码以 PBKDF2 加盐哈希存储,无法还原。</li>
|
||||
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>账户</h3>
|
||||
<button className="btn btn-danger" onClick={handleLogout}>
|
||||
退出登录
|
||||
</button>
|
||||
<section className="sec">
|
||||
<button className="btn btn-danger" onClick={handleLogout}>退出登录</button>
|
||||
</section>
|
||||
</Screen>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import Screen from '../components/Screen';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus,
|
||||
apiClient, AutoSyncStatus, errorMessage, GarminLoginStatus, parseUtc,
|
||||
SyncStatus, UserSettings,
|
||||
} from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import './DataSync.css';
|
||||
|
||||
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() {
|
||||
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);
|
||||
|
||||
// Garmin login (only needed until a token is stored)
|
||||
@@ -31,22 +47,28 @@ function SyncPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setSyncStatus(await apiClient.getGarminSyncStatus());
|
||||
} catch (err) {
|
||||
const [s, a] = await Promise.all([
|
||||
apiClient.getGarminSyncStatus(),
|
||||
apiClient.getAutoSyncStatus().catch(() => null),
|
||||
]);
|
||||
setSyncStatus(s);
|
||||
if (a) setAuto(a);
|
||||
return s;
|
||||
} catch {
|
||||
// A failed status poll should not blank the page.
|
||||
console.error('Failed to load sync status:', err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// A backfill outlives the page, so a reload must pick the progress back up.
|
||||
apiClient.getGarminSyncStatus().then((s) => {
|
||||
setSyncStatus(s);
|
||||
if (s.status === 'syncing') beginSyncPolling();
|
||||
}).catch(() => undefined);
|
||||
refresh().then((s) => {
|
||||
if (s?.status === 'syncing') beginSyncPolling();
|
||||
});
|
||||
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
|
||||
apiClient.getSettings().then(setSettings).catch(() => setSettings(null));
|
||||
return stopPolling;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -88,7 +110,7 @@ function SyncPage() {
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
setHasToken(true);
|
||||
setMessage('Garmin 登录成功,之后同步不再需要密码或验证码。');
|
||||
setMessage('绑定成功,之后同步不再需要密码或验证码。');
|
||||
} else if (status === 'failed') {
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
@@ -140,13 +162,32 @@ function SyncPage() {
|
||||
};
|
||||
|
||||
// --- sync ---------------------------------------------------------------
|
||||
const handleSync = async (days: number) => {
|
||||
|
||||
/** The last couple of days, awaited inline — seconds, not minutes. */
|
||||
const syncLatest = async () => {
|
||||
setError('');
|
||||
setMessage('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.syncGarminData(days);
|
||||
await loadSyncStatus();
|
||||
const result = await apiClient.syncLatest(2);
|
||||
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();
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '同步失败'));
|
||||
@@ -155,29 +196,19 @@ function SyncPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// The sync runs in the background, so the page follows it by polling
|
||||
// rather than by holding a request open for the whole backfill.
|
||||
// The backfill runs in the background, so the page follows it by polling
|
||||
// rather than by holding a request open for the whole thing.
|
||||
const beginSyncPolling = () => {
|
||||
stopPolling();
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const s = await apiClient.getGarminSyncStatus();
|
||||
setSyncStatus(s);
|
||||
if (s.status !== 'syncing') {
|
||||
stopPolling();
|
||||
if (s.status === 'error') setError(s.lastError || '同步失败');
|
||||
else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
|
||||
}
|
||||
} catch {
|
||||
const s = await refresh();
|
||||
if (!s) { stopPolling(); return; }
|
||||
if (s.status !== 'syncing') {
|
||||
stopPolling();
|
||||
if (s.status === 'error') setError(s.lastError || '同步失败');
|
||||
else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
syncing: '正在同步…',
|
||||
error: '上次同步失败',
|
||||
}, POLL_MS);
|
||||
};
|
||||
|
||||
const syncing = syncStatus?.status === 'syncing';
|
||||
@@ -189,176 +220,150 @@ function SyncPage() {
|
||||
|
||||
return (
|
||||
<Screen title="数据同步" backLink>
|
||||
<h2>数据同步</h2>
|
||||
<p className="subtitle">从 Garmin Connect 拉取最近 7 天的健康数据</p>
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
{message && <div className="screen-ok">{message}</div>}
|
||||
|
||||
<div className="sync-container">
|
||||
<section className="status-card">
|
||||
<h3>同步状态</h3>
|
||||
{syncStatus ? (
|
||||
<div className="status-info">
|
||||
<div className="status-item">
|
||||
<span className="label">状态</span>
|
||||
<span className={`value status-${syncStatus.status}`}>
|
||||
{statusLabel[syncStatus.status] ?? syncStatus.status}
|
||||
</span>
|
||||
{/* Step 1 — link the Garmin account, once. */}
|
||||
{hasToken === false && !session && (
|
||||
<form className="sync-card" onSubmit={startLogin}>
|
||||
<h3>绑定 Garmin 账号</h3>
|
||||
<p className="field-hint">
|
||||
只需一次。保存的是授权令牌(约一年有效),密码不会被存储。
|
||||
开启了两步验证的话,下一步会让你填验证码。
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<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 className="status-item">
|
||||
<span className="label">最后同步</span>
|
||||
<span className="value">
|
||||
{parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
|
||||
?? '从未同步'}
|
||||
</span>
|
||||
<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>
|
||||
<div className="status-item">
|
||||
<span className="label">已同步天数</span>
|
||||
<span className="value">{syncStatus.recordsSynced}</span>
|
||||
</div>
|
||||
{syncStatus.lastError && (
|
||||
<div className="status-item error">
|
||||
<span className="label">错误</span>
|
||||
<span className="value">{syncStatus.lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="screen-note">加载中…</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loginState === 'finishing' && (
|
||||
<p className="screen-note">验证通过,正在完成登录…</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 1 — link the Garmin account, once. */}
|
||||
{hasToken === false && !session && (
|
||||
<form className="sync-actions" onSubmit={startLogin}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="garmin-password">Garmin 密码</label>
|
||||
<input
|
||||
id="garmin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{/* Step 3 — the whole page, once linked. Status on top, two actions
|
||||
under it; the old version buried both under four paragraphs. */}
|
||||
{hasToken === true && (
|
||||
<>
|
||||
{syncing ? (
|
||||
<section className="sync-card">
|
||||
<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">
|
||||
只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年),
|
||||
密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。
|
||||
在后台运行,可以离开本页
|
||||
{total > 60 ? `,预计还需 ${Math.ceil(((total - current) * 3) / 60)} 分钟` : ''}。
|
||||
</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>
|
||||
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
|
||||
{loading ? '正在连接…' : '绑定 Garmin 账号'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Step 2 — the two-factor code. */}
|
||||
{session && (
|
||||
<section className="status-card mfa-card">
|
||||
{loginState === 'starting' && !codeSubmitted && (
|
||||
<p className="screen-note">正在连接 Garmin…</p>
|
||||
)}
|
||||
<div className="sync-facts">
|
||||
<div className="sync-fact">
|
||||
<span className="sync-fact-label">上次同步</span>
|
||||
<span className="sync-fact-value">
|
||||
{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 && (
|
||||
<form onSubmit={submitCode}>
|
||||
<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>
|
||||
{syncStatus?.lastError && !syncing && (
|
||||
<p className="sync-lasterror">上次错误:{syncStatus.lastError}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import TrendsPage from './pages/TrendsPage';
|
||||
import MetricDetailPage from './pages/MetricDetailPage';
|
||||
import ActivityDetailPage from './pages/ActivityDetailPage';
|
||||
import BodyAgePage from './pages/BodyAgePage';
|
||||
import RatingBasisPage from './pages/RatingBasisPage';
|
||||
import ExercisePage from './pages/ExercisePage';
|
||||
import SleepPage from './pages/SleepPage';
|
||||
import SyncPage from './pages/SyncPage';
|
||||
@@ -32,6 +33,7 @@ const routes: Router.RouteParameters[] = [
|
||||
{ path: '/metric/:id/', component: MetricDetailPage },
|
||||
{ path: '/activity/:id/', component: ActivityDetailPage },
|
||||
{ path: '/body-age/', component: BodyAgePage },
|
||||
{ path: '/rating-basis/', component: RatingBasisPage },
|
||||
{ path: '/sync/', component: SyncPage },
|
||||
{ path: '/settings/', component: SettingsPage },
|
||||
{ path: '/login/', component: LoginPage },
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
| 2.2 | 验证码在前端输入(人不在电脑前) | Web MFA:后台线程阻塞在 `prompt_mfa`,验证码经数据库跨 worker 交接 | ✅ |
|
||||
| 2.3 | 同步改为用户手动触发,不自动跑 | 同步是界面动作 | ✅ |
|
||||
| 2.4 | 每小时后台同步最新数据 | `job_locks` 抢占,多 worker 只跑一次 | ✅ |
|
||||
| 2.5 | 「同步最新数据」按钮 | `POST /api/garmin/sync-latest`,窗口 1–7 天,同步返回 | ✅ 接口完成,🚧 界面按钮 |
|
||||
| 2.6 | 同步频率可设置 | 30 分 / 1 时 / 3 时 / 6 时 / 12 时 / 1 天 | ✅ 接口完成,🚧 界面 |
|
||||
| 2.7 | 同步历史范围可设置(多久以前 / 全部) | 7/30/90/180/365/730 天,或全部 | ✅ 接口完成,🚧 界面 |
|
||||
| 2.8 | 同步界面简化设计 | 去掉冗长说明,状态 + 两个动作为主 | 📋 |
|
||||
| 2.5 | 「同步最新数据」按钮 | 同步页首要按钮,等待即返回结果 | ✅ |
|
||||
| 2.6 | 同步频率可设置 | 设置页选择,调度器按各账号自己的频率判断是否该同步 | ✅ |
|
||||
| 2.7 | 同步历史范围可设置(多久以前 / 全部) | 设置页选择,同步页「同步历史」按此范围拉取 | ✅ |
|
||||
| 2.8 | 同步界面简化设计 | 两个按钮 + 三格状态,去掉原来四段说明文字 | ✅ |
|
||||
|
||||
## 三、界面框架
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
| 3.2 | Tab:今日 / 健康 / 趋势 / 运动 / 设置 | 每日是趋势的子页 | ✅ |
|
||||
| 3.3 | 首页改三圆环:步数 / 睡眠 / HRV | 并列三环,满环=进入参考区间 | ✅ |
|
||||
| 3.4 | 交互流畅、有动画 | 数字滚动、入场揭示、`prefers-reduced-motion` | ✅ |
|
||||
| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 |
|
||||
| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip)渲染出来的,已移除 | ✅ |
|
||||
| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走;每日入口同样移进趋势页内容 | ✅ |
|
||||
| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 |
|
||||
|
||||
## 四、数据展示
|
||||
|
||||
@@ -61,9 +61,9 @@
|
||||
|
||||
| # | 需求 | 理解 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 5.1 | 设置项太少:身高/体重/年龄/性别 | `user_settings` 表 | ✅ 接口完成,🚧 界面 |
|
||||
| 5.2 | 单位设置 | 公制 / 英制 | ✅ 接口完成,🚧 界面 |
|
||||
| 5.3 | 评分依据要在设置说明中显示 | `GET /api/settings/rating-basis`,逐条列出每个参考区间的出处 | ✅ 接口完成,🚧 界面 |
|
||||
| 5.1 | 设置项太少:身高/体重/年龄/性别 | 设置页个人资料分组,改动即存,另显示 BMI 与年龄 | ✅ |
|
||||
| 5.2 | 单位设置 | 公制 / 英制,已存储;各页按此显示待接入 | ✅ 设置完成,🚧 全局套用 |
|
||||
| 5.3 | 评分依据要在设置说明中显示 | 设置 → 评分依据 `/rating-basis/`,11 项区间逐条列出处;指标详情页也各带一份 | ✅ |
|
||||
|
||||
## 六、AI
|
||||
|
||||
|
||||
Reference in New Issue
Block a user