[阶段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:
@@ -18,9 +18,9 @@ Framework7.use(Framework7React);
|
||||
const TABS = [
|
||||
{ id: 'today', path: '/', label: '今日', icon: 'square_grid_2x2' },
|
||||
{ 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: '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"
|
||||
darkMode="auto"
|
||||
routes={routes}
|
||||
view={{ browserHistory: true, browserHistorySeparator: '' }}
|
||||
touch={{ tapHold: true }}
|
||||
>
|
||||
<Views tabs className="safe-areas">
|
||||
@@ -94,6 +93,11 @@ function App() {
|
||||
tab
|
||||
tabActive={tab.id === 'today'}
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 './Screen.css';
|
||||
|
||||
@@ -46,13 +46,9 @@ function Screen({
|
||||
<Page>
|
||||
<Navbar large={large} transparent={large} title={title} subtitle={subtitle} backLink={backLink ? '返回' : undefined}>
|
||||
<NavRight>
|
||||
{right ?? (
|
||||
<>
|
||||
<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />
|
||||
<Link href="/sync/" iconIos="f7:arrow_2_circlepath" tooltip="同步" />
|
||||
<Link href="/settings/" iconIos="f7:gear_alt" tooltip="设置" />
|
||||
</>
|
||||
)}
|
||||
{/* Only what this screen actually offers — a fixed set of icons on
|
||||
every page turns the navbar into decoration. */}
|
||||
{right}
|
||||
</NavRight>
|
||||
</Navbar>
|
||||
|
||||
|
||||
@@ -202,3 +202,21 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -165,7 +165,7 @@ function DailyPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen title="每日数据">
|
||||
<Screen title="每日数据" backLink>
|
||||
|
||||
<div className="day-nav">
|
||||
<button className="day-btn" onClick={() => shift(-1)} aria-label="前一天">‹</button>
|
||||
|
||||
94
client/src/pages/Exercise.css
Normal file
94
client/src/pages/Exercise.css
Normal 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; }
|
||||
}
|
||||
286
client/src/pages/ExercisePage.tsx
Normal file
286
client/src/pages/ExercisePage.tsx
Normal 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;
|
||||
@@ -114,7 +114,7 @@ function HealthPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
||||
<h2>健康</h2>
|
||||
<Skeleton count={8} />
|
||||
</Screen>
|
||||
@@ -123,7 +123,7 @@ function HealthPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
||||
<h2>健康</h2>
|
||||
<div className="screen-error">{error}</div>
|
||||
</Screen>
|
||||
@@ -132,7 +132,7 @@ function HealthPage() {
|
||||
|
||||
if (days.length === 0) {
|
||||
return (
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
||||
<h2>健康</h2>
|
||||
<div className="screen-empty">
|
||||
<p>还没有任何健康数据。</p>
|
||||
@@ -153,7 +153,7 @@ function HealthPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
||||
|
||||
{SECTIONS.map((section) => (
|
||||
<section className="sec" key={section.title}>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import Screen from '../components/Screen';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, f7 } from 'framework7-react';
|
||||
import { apiClient, ModelInfo } from '../services/api';
|
||||
import { FEATURES } from '../features';
|
||||
import './Settings.css';
|
||||
|
||||
function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -24,7 +23,7 @@ function SettingsPage() {
|
||||
|
||||
const handleLogout = async () => {
|
||||
await apiClient.logout();
|
||||
navigate('/login');
|
||||
f7.views.current.router.navigate('/login/', { reloadAll: true });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -76,6 +75,14 @@ function SettingsPage() {
|
||||
</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">
|
||||
<h3>数据与隐私</h3>
|
||||
<ul className="settings-list">
|
||||
|
||||
@@ -76,7 +76,7 @@ function SleepPage() {
|
||||
const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
|
||||
|
||||
return (
|
||||
<Screen title="睡眠" subtitle="分期、评分与夜间生理指标">
|
||||
<Screen title="睡眠" backLink subtitle="分期、评分与夜间生理指标">
|
||||
|
||||
<div className="segmented-row">
|
||||
<span className="segmented-label">范围</span>
|
||||
|
||||
@@ -188,7 +188,7 @@ function SyncPage() {
|
||||
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Screen title="数据同步">
|
||||
<Screen title="数据同步" backLink>
|
||||
<h2>数据同步</h2>
|
||||
<p className="subtitle">从 Garmin Connect 拉取最近 7 天的健康数据</p>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import Chart, { Series } from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
@@ -220,7 +221,7 @@ function TrendsPage() {
|
||||
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
|
||||
|
||||
return (
|
||||
<Screen title="趋势">
|
||||
<Screen title="趋势" right={<Link href="/daily/" iconIos="f7:calendar" tooltip="每日数据" />}>
|
||||
|
||||
<div className="segmented-row">
|
||||
<span className="segmented-label">范围</span>
|
||||
|
||||
@@ -4,7 +4,7 @@ import TodayPage from './pages/TodayPage';
|
||||
import HealthPage from './pages/HealthPage';
|
||||
import DailyPage from './pages/DailyPage';
|
||||
import TrendsPage from './pages/TrendsPage';
|
||||
import AchievementsPage from './pages/AchievementsPage';
|
||||
import ExercisePage from './pages/ExercisePage';
|
||||
import SleepPage from './pages/SleepPage';
|
||||
import SyncPage from './pages/SyncPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
@@ -12,16 +12,19 @@ import LoginPage from './pages/LoginPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
|
||||
/**
|
||||
* Secondary screens are reachable from more than one tab, so they are
|
||||
* registered on every view's router rather than pinned to one — pushing 睡眠
|
||||
* from 今日 should stay inside 今日's stack.
|
||||
* Every route is registered on every view's router, so a detail screen opens
|
||||
* inside whichever tab the user was in: 睡眠 pushed from 今日 stays in 今日's
|
||||
* 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[] = [
|
||||
{ path: '/', component: TodayPage },
|
||||
{ path: '/health/', component: HealthPage },
|
||||
{ path: '/daily/', component: DailyPage },
|
||||
{ path: '/trends/', component: TrendsPage },
|
||||
{ path: '/achievements/', component: AchievementsPage },
|
||||
{ path: '/exercise/', component: ExercisePage },
|
||||
{ path: '/sleep/', component: SleepPage },
|
||||
{ path: '/sync/', component: SyncPage },
|
||||
{ path: '/settings/', component: SettingsPage },
|
||||
|
||||
Reference in New Issue
Block a user