[阶段10.1] Tab 改为 今日/健康/趋势/运动/设置

按指定结构调整导航:每日归入趋势、睡眠归入健康、同步归入设置,
它们是各自 Tab 的详情视图而非独立目的地。新增「运动」Tab。

运动页(新):
- 近 30 天运动时长/距离/消耗,以及今日强度分钟
- 项目分布:按累计时长排序,横条表示占比。只用一个色相 —— 每个项目
  一种颜色会让人以为分类另有含义
- 强度分钟趋势图,附 WHO 每周 150 分钟的参考
- 记录 / 个人纪录 / 奖励三个分页,运动记录 174 条

fix(nav): 五个 Tab 全部加载了同一个页面
- browserHistory 开在每个 View 上,导致它们都去读浏览器地址栏(当时是
  "/")而不是各自的 url,五个 Tab 渲染出五份「今日」
- 浏览器历史只绑定主 View,其余 Tab 用自己的 url 独立加载

fix(nav): 导航栏右侧对所有页面塞同一组图标
- 睡眠/同步/设置三个图标出现在每个页面上,其中两个在多数页面无意义。
  改为按页面声明:趋势 → 每日,健康 → 睡眠,设置 → 同步

fix(viz): 图表标题在窄栏里竖排
- 标题与「看数据」按钮争抢宽度,短标题被压成竖排字符。
  标题允许收缩省略,按钮不收缩

fix: 未映射的运动类型直接暴露 snake_case
- 补充登山/划船/椭圆机等映射;仍未覆盖的转为空格分词并首字母大写,
  而不是把 Garmin 的原始 key 摆给用户看

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 23:52:18 +08:00
parent 7ab150537d
commit fd9b610810
13 changed files with 436 additions and 236 deletions

View File

@@ -18,9 +18,9 @@ Framework7.use(Framework7React);
const TABS = [ const TABS = [
{ id: 'today', path: '/', label: '今日', icon: 'square_grid_2x2' }, { id: 'today', path: '/', label: '今日', icon: 'square_grid_2x2' },
{ id: 'health', path: '/health/', label: '健康', icon: 'heart' }, { id: 'health', path: '/health/', label: '健康', icon: 'heart' },
{ id: 'daily', path: '/daily/', label: '每日', icon: 'calendar' },
{ id: 'trends', path: '/trends/', label: '趋势', icon: 'chart_bar_alt_fill' }, { id: 'trends', path: '/trends/', label: '趋势', icon: 'chart_bar_alt_fill' },
{ id: 'awards', path: '/achievements/', label: '成就', icon: 'rosette' }, { id: 'exercise', path: '/exercise/', label: '运动', icon: 'flame' },
{ id: 'settings', path: '/settings/', label: '设置', icon: 'gear_alt' },
]; ];
/** /**
@@ -66,7 +66,6 @@ function App() {
theme="ios" theme="ios"
darkMode="auto" darkMode="auto"
routes={routes} routes={routes}
view={{ browserHistory: true, browserHistorySeparator: '' }}
touch={{ tapHold: true }} touch={{ tapHold: true }}
> >
<Views tabs className="safe-areas"> <Views tabs className="safe-areas">
@@ -94,6 +93,11 @@ function App() {
tab tab
tabActive={tab.id === 'today'} tabActive={tab.id === 'today'}
url={tab.path} url={tab.path}
// Only the main view drives the address bar. With it enabled on
// every tab each one reads the browser URL instead of its own
// `url`, so all five loaded the root page.
browserHistory={tab.id === 'today'}
browserHistorySeparator=""
/> />
))} ))}
</Views> </Views>

View File

@@ -1,5 +1,5 @@
import { ReactNode, useEffect, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { Page, Navbar, NavRight, Link, f7 } from 'framework7-react'; import { Page, Navbar, NavRight, f7 } from 'framework7-react';
import { apiClient } from '../services/api'; import { apiClient } from '../services/api';
import './Screen.css'; import './Screen.css';
@@ -46,13 +46,9 @@ function Screen({
<Page> <Page>
<Navbar large={large} transparent={large} title={title} subtitle={subtitle} backLink={backLink ? '返回' : undefined}> <Navbar large={large} transparent={large} title={title} subtitle={subtitle} backLink={backLink ? '返回' : undefined}>
<NavRight> <NavRight>
{right ?? ( {/* Only what this screen actually offers — a fixed set of icons on
<> every page turns the navbar into decoration. */}
<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" /> {right}
<Link href="/sync/" iconIos="f7:arrow_2_circlepath" tooltip="同步" />
<Link href="/settings/" iconIos="f7:gear_alt" tooltip="设置" />
</>
)}
</NavRight> </NavRight>
</Navbar> </Navbar>

View File

@@ -202,3 +202,21 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.viz { animation: none; transition: none; } .viz { animation: none; transition: none; }
} }
/* The header must not let a short title collapse into vertical characters
when the toggle button claims the row's width. */
.viz-head h4 {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.viz-head > div {
min-width: 0;
flex: 1;
}
.viz-toggle {
flex-shrink: 0;
}

View File

@@ -1,209 +0,0 @@
import { useEffect, useState } from 'react';
import {
apiClient, Activity, Badge, errorMessage, PersonalRecord,
} from '../services/api';
import StatTile from '../components/charts/StatTile';
import Skeleton from '../components/Skeleton';
import Screen from '../components/Screen';
type Tab = 'badges' | 'records' | 'activities';
const ACTIVITY_LABEL: Record<string, string> = {
running: '跑步',
cycling: '骑行',
walking: '步行',
hiking: '徒步',
swimming: '游泳',
table_tennis: '乒乓球',
strength_training: '力量训练',
indoor_cycling: '室内骑行',
treadmill_running: '跑步机',
fitness_equipment: '健身器械',
};
const label = (key: string | null) =>
key ? ACTIVITY_LABEL[key] ?? key.replace(/_/g, ' ') : '—';
const date = (value: string | null) => (value ? value.slice(0, 10) : '—');
function AchievementsPage() {
const [tab, setTab] = useState<Tab>('badges');
const [badges, setBadges] = useState<Badge[]>([]);
const [records, setRecords] = useState<PersonalRecord[]>([]);
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
const [b, r, a] = await Promise.all([
apiClient.getBadges(),
apiClient.getPersonalRecords(),
apiClient.getActivities(),
]);
setBadges(b);
setRecords(r);
setActivities(a);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
}, []);
if (loading) {
return (
<Screen title="成就" subtitle="奖励徽章、个人纪录与运动记录">
<h2></h2>
<Skeleton count={4} />
</Screen>
);
}
// Badges cluster heavily by year, which is the only grouping that reads.
const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => {
const year = b.earned_date ? b.earned_date.slice(0, 4) : '未知';
(acc[year] ||= []).push(b);
return acc;
}, {});
const years = Object.keys(byYear).sort().reverse();
const totalPoints = badges.reduce((sum, b) => sum + (b.points ?? 0), 0);
return (
<Screen title="成就" subtitle="奖励徽章、个人纪录与运动记录">
{error && <div className="screen-error">{error}</div>}
<div className="tile-grid" style={{ marginBottom: '1.5rem' }}>
<StatTile label="奖励徽章" value={badges.length} unit="个" />
<StatTile
label="徽章积分"
value={totalPoints || null}
detail={totalPoints ? undefined : '该账号未记录积分'}
/>
<StatTile label="个人纪录" value={records.length} unit="项" />
<StatTile label="运动记录" value={activities.length} unit="条" />
</div>
<div className="metric-tabs">
{([
['badges', `奖励 (${badges.length})`],
['records', `个人纪录 (${records.length})`],
['activities', `运动 (${activities.length})`],
] as Array<[Tab, string]>).map(([id, text]) => (
<button
key={id}
className={`metric-tab ${tab === id ? 'active' : ''}`}
onClick={() => setTab(id)}
>
{text}
</button>
))}
</div>
{tab === 'badges' && (
badges.length === 0 ? (
<p className="screen-note"></p>
) : (
years.map((year) => (
<section className="sec" key={year}>
<h3 className="sec-title">
{year === '未知' ? '未知年份' : `${year}`}
<span className="sec-count">{byYear[year].length} </span>
</h3>
<div className="badge-grid">
{byYear[year].map((b) => (
<div className="badge" key={b.id}>
<div className="badge-name">{b.name || b.badge_key}</div>
<div className="badge-meta">
{date(b.earned_date)}
{b.earned_count && b.earned_count > 1 && (
<span className="badge-count">×{b.earned_count}</span>
)}
</div>
</div>
))}
</div>
</section>
))
)
)}
{tab === 'records' && (
records.length === 0 ? (
<p className="screen-note"></p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id}>
<th scope="row">{r.activity_name || '—'}</th>
<td>{label(r.activity_type)}</td>
<td className="num">
{r.value != null ? r.value.toLocaleString(undefined, {
maximumFractionDigits: 2,
}) : '—'}
</td>
<td>{date(r.achieved_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{tab === 'activities' && (
activities.length === 0 ? (
<p className="screen-note"></p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={a.id}>
<th scope="row">{a.start_time?.slice(0, 16).replace('T', ' ')}</th>
<td>{label(a.activity_type)}</td>
<td className="num">
{a.duration != null ? `${Math.round(a.duration / 60)}` : '—'}
</td>
<td className="num">
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
</td>
<td className="num">{a.calories != null ? `${Math.round(a.calories)}` : '—'}</td>
<td className="num">{a.heart_rate_average ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</Screen>
);
}
export default AchievementsPage;

View File

@@ -165,7 +165,7 @@ function DailyPage() {
}; };
return ( return (
<Screen title="每日数据"> <Screen title="每日数据" backLink>
<div className="day-nav"> <div className="day-nav">
<button className="day-btn" onClick={() => shift(-1)} aria-label="前一天"></button> <button className="day-btn" onClick={() => shift(-1)} aria-label="前一天"></button>

View File

@@ -0,0 +1,94 @@
/* Sport split ------------------------------------------------------------- */
.sport-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.sport {
display: flex;
align-items: center;
gap: 0.7rem;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.7rem 0.85rem;
}
.sport-icon { font-size: 1.25rem; line-height: 1; flex-shrink: 0; }
.sport-body { flex: 1; min-width: 0; }
.sport-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.sport-name { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }
.sport-stat { font-size: 0.74rem; color: var(--text-muted); white-space: nowrap; }
.sport-bar {
height: 5px;
background: var(--surface-0);
border-radius: 999px;
overflow: hidden;
}
/* One hue, length carries the share — a different colour per sport would
imply categories that mean something beyond "which row is this". */
.sport-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 0.8s var(--ease);
}
/* Activity list ----------------------------------------------------------- */
.act-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.act {
display: flex;
align-items: center;
gap: 0.7rem;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.7rem 0.85rem;
transition: border-color 0.15s var(--ease), transform 0.15s var(--ease);
}
.act:active { transform: scale(0.99); }
.act-icon { font-size: 1.2rem; line-height: 1; flex-shrink: 0; }
.act-body { flex: 1; min-width: 0; }
.act-name { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }
.act-meta { font-size: 0.73rem; color: var(--text-muted); margin-top: 0.15rem; }
.act-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.1rem;
font-size: 0.76rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.act-stats span:first-child {
color: var(--text-primary);
font-weight: 600;
font-size: 0.85rem;
}
@media (prefers-reduced-motion: reduce) {
.sport-fill { transition: none; }
.act { transition: none; }
.act:active { transform: none; }
}

View File

@@ -0,0 +1,286 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import {
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
} from '../services/api';
import Screen from '../components/Screen';
import MetricCard from '../components/charts/MetricCard';
import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import './Exercise.css';
const WINDOW_DAYS = 30;
type Tab = 'activities' | 'records' | 'badges';
const ACTIVITY_LABEL: Record<string, string> = {
running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步',
swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练',
indoor_cycling: '室内骑行', treadmill_running: '跑步机',
fitness_equipment: '健身器械', badminton: '羽毛球', yoga: '瑜伽',
mountaineering: '登山', open_water_swimming: '公开水域游泳',
elliptical: '椭圆机', rowing: '划船', stair_climbing: '爬楼梯',
};
const ACTIVITY_ICON: Record<string, string> = {
running: '🏃', cycling: '🚴', walking: '🚶', hiking: '🥾',
swimming: '🏊', table_tennis: '🏓', strength_training: '🏋️',
indoor_cycling: '🚴', treadmill_running: '🏃', badminton: '🏸', yoga: '🧘',
};
const label = (k: string | null) => {
if (!k) return '—';
const known = ACTIVITY_LABEL[k];
if (known) return known;
// Unmapped types come through as Garmin's raw key; make it readable rather
// than leaking snake_case into the UI.
return k.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
};
const icon = (k: string | null) => (k ? ACTIVITY_ICON[k] ?? '🏅' : '🏅');
const day = (v: string | null) => (v ? v.slice(0, 10) : '—');
function ExercisePage() {
const [tab, setTab] = useState<Tab>('activities');
const [activities, setActivities] = useState<Activity[]>([]);
const [records, setRecords] = useState<PersonalRecord[]>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [days, setDays] = useState<HealthDay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
const end = new Date();
const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000);
const [a, r, b, d] = await Promise.all([
apiClient.getActivities(),
apiClient.getPersonalRecords(),
apiClient.getBadges(),
apiClient.getHealthSummary(
start.toISOString().slice(0, 10), end.toISOString().slice(0, 10)
),
]);
setActivities(a);
setRecords(r);
setBadges(b);
setDays(d);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
}, []);
const recent = useMemo(() => {
const cutoff = new Date(Date.now() - WINDOW_DAYS * 86400000)
.toISOString().slice(0, 10);
return activities.filter((a) => (a.start_time ?? '') >= cutoff);
}, [activities]);
const totalMinutes = Math.round(
recent.reduce((sum, a) => sum + (a.duration ?? 0), 0) / 60
);
const totalDistance =
recent.reduce((sum, a) => sum + (a.distance ?? 0), 0) / 1000;
const totalCalories = Math.round(
recent.reduce((sum, a) => sum + (a.calories ?? 0), 0)
);
/* Which sports, and how much of each — the split is the useful reading, not
a bare session count. */
const byType = useMemo(() => {
const map = new Map<string, { minutes: number; count: number }>();
for (const a of recent) {
const key = a.activity_type ?? 'unknown';
const cur = map.get(key) ?? { minutes: 0, count: 0 };
cur.minutes += Math.round((a.duration ?? 0) / 60);
cur.count += 1;
map.set(key, cur);
}
return [...map.entries()].sort((a, b) => b[1].minutes - a[1].minutes);
}, [recent]);
const intensityRows = days.map((d) => ({
date: d.date.slice(5),
intensityMinutes: d.intensityMinutes,
}));
const badgesByYear = useMemo(() => {
const map = badges.reduce<Record<string, Badge[]>>((acc, b) => {
const y = b.earned_date ? b.earned_date.slice(0, 4) : '未知';
(acc[y] ||= []).push(b);
return acc;
}, {});
return Object.keys(map).sort().reverse().map((y) => [y, map[y]] as const);
}, [badges]);
if (loading) {
return (
<Screen title="运动">
<Skeleton count={4} />
</Screen>
);
}
return (
<Screen title="运动" subtitle={`${WINDOW_DAYS}`}>
{error && <div className="screen-error">{error}</div>}
<section className="sec">
<div className="mcard-grid">
<MetricCard label="运动时长" value={totalMinutes} unit="分钟"
detail={`${recent.length} 次运动`} />
<MetricCard label="运动距离" value={totalDistance} unit="km" decimals={1} />
<MetricCard label="运动消耗" value={totalCalories} unit="kcal" />
<MetricCard metric="intensityMinutes" label="今日强度分钟"
value={days[days.length - 1]?.intensityMinutes ?? null} unit="分钟"
trend={days.map((d) => d.intensityMinutes)} />
</div>
</section>
{byType.length > 0 && (
<section className="sec">
<h3 className="sec-title"></h3>
<div className="sport-list">
{byType.map(([type, s]) => {
const share = totalMinutes ? (s.minutes / totalMinutes) * 100 : 0;
return (
<div className="sport" key={type}>
<span className="sport-icon" aria-hidden="true">{icon(type)}</span>
<div className="sport-body">
<div className="sport-head">
<span className="sport-name">{label(type)}</span>
<span className="sport-stat">{s.minutes} · {s.count} </span>
</div>
<div className="sport-bar">
<div className="sport-fill" style={{ width: `${share}%` }} />
</div>
</div>
</div>
);
})}
</div>
</section>
)}
<section className="sec">
<Chart
title="强度分钟"
unit="分钟"
data={intensityRows}
type="bar"
height={190}
series={[{ key: 'intensityMinutes', label: '强度分钟', slot: 1, unit: '分钟' }]}
footer="中等及以上强度;世界卫生组织建议每周累计 150 分钟。"
/>
</section>
<div className="metric-tabs">
{([
['activities', `记录 (${activities.length})`],
['records', `个人纪录 (${records.length})`],
['badges', `奖励 (${badges.length})`],
] as Array<[Tab, string]>).map(([id, text]) => (
<button
key={id}
className={`metric-tab ${tab === id ? 'active' : ''}`}
onClick={() => setTab(id)}
>
{text}
</button>
))}
</div>
{tab === 'activities' && (
activities.length === 0 ? (
<div className="screen-empty">
<p></p>
<Link href="/sync/" className="button button-fill button-round"></Link>
</div>
) : (
<div className="act-list">
{activities.map((a) => (
<div className="act" key={a.id}>
<span className="act-icon" aria-hidden="true">{icon(a.activity_type)}</span>
<div className="act-body">
<div className="act-name">{label(a.activity_type)}</div>
<div className="act-meta">
{a.start_time?.slice(0, 16).replace('T', ' ')}
</div>
</div>
<div className="act-stats">
<span>{a.duration != null ? `${Math.round(a.duration / 60)}` : '—'}</span>
{a.distance ? <span>{(a.distance / 1000).toFixed(2)} km</span> : null}
{a.calories != null ? <span>{Math.round(a.calories)} kcal</span> : null}
</div>
</div>
))}
</div>
)
)}
{tab === 'records' && (
records.length === 0 ? (
<p className="screen-note"></p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col"></th><th scope="col"></th>
<th scope="col"></th><th scope="col"></th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id}>
<th scope="row">{r.activity_name || '—'}</th>
<td>{label(r.activity_type)}</td>
<td className="num">
{r.value != null
? r.value.toLocaleString(undefined, { maximumFractionDigits: 2 })
: '—'}
</td>
<td>{day(r.achieved_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{tab === 'badges' && (
badges.length === 0 ? (
<p className="screen-note"></p>
) : (
badgesByYear.map(([year, list]) => (
<section className="sec" key={year}>
<h3 className="sec-title">
{year === '未知' ? '未知年份' : `${year}`}
<span className="sec-count">{list.length} </span>
</h3>
<div className="badge-grid">
{list.map((b) => (
<div className="badge" key={b.id}>
<div className="badge-name">{b.name || b.badge_key}</div>
<div className="badge-meta">
{day(b.earned_date)}
{b.earned_count && b.earned_count > 1 && (
<span className="badge-count">×{b.earned_count}</span>
)}
</div>
</div>
))}
</div>
</section>
))
)
)}
</Screen>
);
}
export default ExercisePage;

View File

@@ -114,7 +114,7 @@ function HealthPage() {
if (loading) { if (loading) {
return ( return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间"> <Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
<h2></h2> <h2></h2>
<Skeleton count={8} /> <Skeleton count={8} />
</Screen> </Screen>
@@ -123,7 +123,7 @@ function HealthPage() {
if (error) { if (error) {
return ( return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间"> <Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
<h2></h2> <h2></h2>
<div className="screen-error">{error}</div> <div className="screen-error">{error}</div>
</Screen> </Screen>
@@ -132,7 +132,7 @@ function HealthPage() {
if (days.length === 0) { if (days.length === 0) {
return ( return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间"> <Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
<h2></h2> <h2></h2>
<div className="screen-empty"> <div className="screen-empty">
<p></p> <p></p>
@@ -153,7 +153,7 @@ function HealthPage() {
}; };
return ( return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间"> <Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
{SECTIONS.map((section) => ( {SECTIONS.map((section) => (
<section className="sec" key={section.title}> <section className="sec" key={section.title}>

View File

@@ -1,12 +1,11 @@
import Screen from '../components/Screen'; import Screen from '../components/Screen';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link, f7 } from 'framework7-react';
import { apiClient, ModelInfo } from '../services/api'; import { apiClient, ModelInfo } from '../services/api';
import { FEATURES } from '../features'; import { FEATURES } from '../features';
import './Settings.css'; import './Settings.css';
function SettingsPage() { function SettingsPage() {
const navigate = useNavigate();
const [models, setModels] = useState<ModelInfo[]>([]); const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -24,7 +23,7 @@ function SettingsPage() {
const handleLogout = async () => { const handleLogout = async () => {
await apiClient.logout(); await apiClient.logout();
navigate('/login'); f7.views.current.router.navigate('/login/', { reloadAll: true });
}; };
return ( return (
@@ -76,6 +75,14 @@ function SettingsPage() {
</section> </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>
<section className="settings-section"> <section className="settings-section">
<h3></h3> <h3></h3>
<ul className="settings-list"> <ul className="settings-list">

View File

@@ -76,7 +76,7 @@ function SleepPage() {
const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d); const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
return ( return (
<Screen title="睡眠" subtitle="分期、评分与夜间生理指标"> <Screen title="睡眠" backLink subtitle="分期、评分与夜间生理指标">
<div className="segmented-row"> <div className="segmented-row">
<span className="segmented-label"></span> <span className="segmented-label"></span>

View File

@@ -188,7 +188,7 @@ function SyncPage() {
const pct = total > 0 ? Math.round((current / total) * 100) : 0; const pct = total > 0 ? Math.round((current / total) * 100) : 0;
return ( return (
<Screen title="数据同步"> <Screen title="数据同步" backLink>
<h2></h2> <h2></h2>
<p className="subtitle"> Garmin Connect 7 </p> <p className="subtitle"> Garmin Connect 7 </p>

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, errorMessage, HealthDay } from '../services/api'; import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart, { Series } from '../components/charts/Chart'; import Chart, { Series } from '../components/charts/Chart';
import Skeleton from '../components/Skeleton'; import Skeleton from '../components/Skeleton';
@@ -220,7 +221,7 @@ function TrendsPage() {
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? ''; GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
return ( return (
<Screen title="趋势"> <Screen title="趋势" right={<Link href="/daily/" iconIos="f7:calendar" tooltip="每日数据" />}>
<div className="segmented-row"> <div className="segmented-row">
<span className="segmented-label"></span> <span className="segmented-label"></span>

View File

@@ -4,7 +4,7 @@ import TodayPage from './pages/TodayPage';
import HealthPage from './pages/HealthPage'; import HealthPage from './pages/HealthPage';
import DailyPage from './pages/DailyPage'; import DailyPage from './pages/DailyPage';
import TrendsPage from './pages/TrendsPage'; import TrendsPage from './pages/TrendsPage';
import AchievementsPage from './pages/AchievementsPage'; 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';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
@@ -12,16 +12,19 @@ import LoginPage from './pages/LoginPage';
import NotFoundPage from './pages/NotFoundPage'; import NotFoundPage from './pages/NotFoundPage';
/** /**
* Secondary screens are reachable from more than one tab, so they are * Every route is registered on every view's router, so a detail screen opens
* registered on every view's router rather than pinned to one — pushing 睡眠 * inside whichever tab the user was in: 睡眠 pushed from 今日 stays in 今日's
* from 今日 should stay inside 今日's stack. * stack rather than yanking them over to 健康.
*
* 每日 sits under 趋势, 睡眠 under 健康 and 同步 under 设置 — they are detail
* views of those tabs, not destinations of their own.
*/ */
const routes: Router.RouteParameters[] = [ const routes: Router.RouteParameters[] = [
{ path: '/', component: TodayPage }, { path: '/', component: TodayPage },
{ path: '/health/', component: HealthPage }, { path: '/health/', component: HealthPage },
{ path: '/daily/', component: DailyPage }, { path: '/daily/', component: DailyPage },
{ path: '/trends/', component: TrendsPage }, { path: '/trends/', component: TrendsPage },
{ path: '/achievements/', component: AchievementsPage }, { path: '/exercise/', component: ExercisePage },
{ path: '/sleep/', component: SleepPage }, { path: '/sleep/', component: SleepPage },
{ path: '/sync/', component: SyncPage }, { path: '/sync/', component: SyncPage },
{ path: '/settings/', component: SettingsPage }, { path: '/settings/', component: SettingsPage },