[阶段7] 前端重做:31 项指标全部露出,配色经色盲校验

原来仪表板只有 4 张卡片和 4 张图,31 项指标里绝大多数没有出口。

信息架构重组为 7 个页面(原 5 个):
- 今日   按活动 / 心率压力 / 睡眠呼吸三组展示 16 项指标
- 趋势   15 组指标可切换,5 档时间窗口(最长一年)
- 睡眠   新增。分期堆叠图 + 夜间血氧/呼吸/压力
- 成就   新增。奖励徽章按年份分组、个人纪录、运动记录
- 建议 / 同步 / 设置 保持

配色(依据 dataviz 规范,用校验器实测而非目测):
- 采用验证过的分类色板并按既定 slot 顺序取色 —— 顺序本身就是
  色盲安全机制,不是审美选择,因此只取不循环
- 浅色 worst adjacent CVD ΔE 9.1 / 常视觉 22.9
  深色 worst adjacent CVD ΔE 8.4 / 常视觉 19.8,两档全部通过
- 浅色表面下 aqua 2.74:1、yellow 2.11:1 低于 3:1,按规范提供
  "relief":每张图都带表格视图,且图例始终与文字标签同现
- 深色不是自动反色,是同色相针对深色表面重新取阶并单独校验
- 状态色(good/warning/serious/critical)保留专用,绝不当作
  第 N 个系列色;且始终图标 + 文字同现,不靠颜色单独表意

图表规范:
- 单一 y 轴,绝不双轴;量纲不同的指标拆成不同图
- 2px 线宽、4px 圆角柱端锚定基线、堆叠段间 2px 表面色间隙、
  悬停标记 2px 表面色描边
- 两系列以上必有图例,单系列不加(标题已经点明)
- 折线 connectNulls,设备漏记的日子不把线打断
- 数值文字一律用文字色令牌,不染系列色

其他:
- 主题切换(自动/浅色/深色),选择写入 localStorage 并标在 <html>
- 导航改为顶部横向,移动端可横滑
- 表格、徽章、空状态等组件统一到设计令牌

验证方式: DOM 审计确认 2px 线宽、圆角为 A 4,4 弧、堆叠段
2px 间隙、图例数量与系列数匹配、两档主题令牌各自解析到校验过的色值。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 21:17:08 +08:00
parent cbbff61082
commit ad88ec7e41
20 changed files with 2131 additions and 767 deletions

View File

@@ -2,9 +2,9 @@
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"name": "client",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"runtimeArgs": ["start", "--workspace=client"],
"port": 3000
}
]

View File

@@ -4,9 +4,11 @@ import ProtectedRoute from './components/ProtectedRoute';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import DataSync from './pages/DataSync';
import Analysis from './pages/Analysis';
import Trends from './pages/Trends';
import Recommendations from './pages/Recommendations';
import Settings from './pages/Settings';
import Sleep from './pages/Sleep';
import Achievements from './pages/Achievements';
function App() {
return (
@@ -22,7 +24,9 @@ function App() {
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sync" element={<DataSync />} />
<Route path="/analysis" element={<Analysis />} />
<Route path="/trends" element={<Trends />} />
<Route path="/sleep" element={<Sleep />} />
<Route path="/achievements" element={<Achievements />} />
<Route path="/recommendations" element={<Recommendations />} />
<Route path="/settings" element={<Settings />} />
</Routes>

View File

@@ -1,76 +0,0 @@
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
interface ChartProps {
title: string;
data: Array<Record<string, any>>;
type: 'line' | 'bar';
dataKey: string;
stroke?: string;
fill?: string;
height?: number;
}
function Chart({
title,
data,
type,
dataKey,
stroke = '#667eea',
fill = '#667eea',
height = 260,
}: ChartProps) {
// A row may carry some metrics and not others, so emptiness is decided per
// metric rather than by the length of `data`.
const hasValues = data.some((row) => row[dataKey] != null);
if (!hasValues) {
return (
<div className="chart-container">
<h4>{title}</h4>
<div className="chart-placeholder"></div>
</div>
);
}
const axisProps = { fontSize: 12, stroke: '#999' };
return (
<div className="chart-container">
<h4>{title}</h4>
<ResponsiveContainer width="100%" height={height}>
{type === 'line' ? (
<LineChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" {...axisProps} />
<YAxis {...axisProps} domain={['auto', 'auto']} />
<Tooltip />
{/* connectNulls keeps the line continuous across days the device
did not record, instead of breaking it into fragments. */}
<Line
type="monotone"
dataKey={dataKey}
stroke={stroke}
strokeWidth={2}
dot={{ r: 3 }}
connectNulls
name={title}
/>
</LineChart>
) : (
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" {...axisProps} />
<YAxis {...axisProps} />
<Tooltip />
<Bar dataKey={dataKey} fill={fill} radius={[3, 3, 0, 0]} name={title} />
</BarChart>
)}
</ResponsiveContainer>
</div>
);
}
export default Chart;

View File

@@ -1,129 +1,93 @@
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f5f5f5;
background: var(--surface-0);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: var(--surface-1);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 20;
}
.header-content {
max-width: 1400px;
.header-inner {
max-width: 1180px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
height: 56px;
}
.logo {
font-size: 1.8rem;
font-weight: bold;
margin-bottom: 0.5rem;
font-size: 0.95rem;
font-weight: 680;
color: var(--text-primary);
text-decoration: none;
white-space: nowrap;
}
.tagline {
font-size: 0.9rem;
opacity: 0.9;
}
.container {
.nav {
display: flex;
gap: 0.15rem;
flex: 1;
max-width: 1400px;
width: 100%;
margin: 0 auto;
gap: 2rem;
padding: 2rem;
overflow-x: auto;
scrollbar-width: none;
}
.sidebar {
width: 250px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
height: fit-content;
position: sticky;
top: 2rem;
}
.nav-list {
list-style: none;
padding: 0;
}
.nav::-webkit-scrollbar { display: none; }
.nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
padding: 0.4rem 0.7rem;
border-radius: 7px;
color: var(--text-secondary);
text-decoration: none;
color: #333;
border-left: 3px solid transparent;
transition: all 0.3s ease;
font-size: 0.88rem;
white-space: nowrap;
transition: background 0.15s ease, color 0.15s ease;
}
.nav-link:hover {
background-color: #f5f5f5;
border-left-color: #667eea;
background: var(--surface-0);
color: var(--text-primary);
}
.nav-link.active {
background-color: #f0f0ff;
border-left-color: #667eea;
color: #667eea;
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
}
.icon {
font-size: 1.2rem;
.theme-toggle {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: 7px;
padding: 0.3rem 0.6rem;
font-size: 0.76rem;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
}
.label {
flex: 1;
.theme-toggle:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
.content {
flex: 1;
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-width: 1180px;
margin: 0 auto;
padding: 1.75rem 1.5rem 4rem;
}
.footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.9rem;
border-top: 1px solid #eee;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
padding: 1rem;
}
.sidebar {
width: 100%;
position: static;
}
.nav-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
}
.nav-link {
flex: 1;
min-width: 100px;
font-size: 0.9rem;
}
.logo {
font-size: 1.4rem;
}
@media (max-width: 720px) {
.header-inner {
gap: 0.75rem;
padding: 0 0.9rem;
}
.logo { font-size: 0.85rem; }
.content { padding: 1.25rem 0.9rem 3rem; }
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import './Layout.css';
@@ -6,51 +6,81 @@ interface LayoutProps {
children: React.ReactNode;
}
const NAV = [
{ path: '/', label: '今日' },
{ path: '/trends', label: '趋势' },
{ path: '/sleep', label: '睡眠' },
{ path: '/achievements', label: '成就' },
{ path: '/recommendations', label: '建议' },
{ path: '/sync', label: '同步' },
{ path: '/settings', label: '设置' },
];
type Theme = 'light' | 'dark' | 'system';
/* Dark mode is a deliberate, validated palette rather than an inverted one, so
the choice is stamped on <html> and the tokens swap in one place. */
function useTheme(): [Theme, (t: Theme) => void] {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem('ghl_theme') as Theme) || 'system'
);
useEffect(() => {
const root = document.documentElement;
if (theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme);
localStorage.setItem('ghl_theme', theme);
}, [theme]);
return [theme, setTheme];
}
function Layout({ children }: LayoutProps) {
const location = useLocation();
const [theme, setTheme] = useTheme();
const navigationItems = [
{ path: '/', label: '仪表板', icon: '📊' },
{ path: '/sync', label: '数据同步', icon: '🔄' },
{ path: '/analysis', label: '数据分析', icon: '📈' },
{ path: '/recommendations', label: '健康建议', icon: '💡' },
{ path: '/settings', label: '设置', icon: '⚙️' },
];
const cycle = () =>
setTheme(theme === 'system' ? 'light' : theme === 'light' ? 'dark' : 'system');
const themeLabel = { system: '跟随系统', light: '浅色', dark: '深色' }[theme];
return (
<div className="layout">
<header className="header">
<div className="header-content">
<h1 className="logo">🏃 Garmin Health Lab</h1>
<p className="tagline"></p>
<div className="header-inner">
<Link to="/" className="logo">Garmin Health Lab</Link>
<nav className="nav" aria-label="主导航">
{NAV.map((item) => {
const active =
item.path === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.path);
return (
<Link
key={item.path}
to={item.path}
className={`nav-link ${active ? 'active' : ''}`}
aria-current={active ? 'page' : undefined}
>
{item.label}
</Link>
);
})}
</nav>
<button
className="theme-toggle"
onClick={cycle}
title={`主题:${themeLabel}`}
aria-label={`切换主题,当前${themeLabel}`}
>
{theme === 'dark' ? '深色' : theme === 'light' ? '浅色' : '自动'}
</button>
</div>
</header>
<div className="container">
<nav className="sidebar">
<ul className="nav-list">
{navigationItems.map(item => (
<li key={item.path}>
<Link
to={item.path}
className={`nav-link ${location.pathname === item.path ? 'active' : ''}`}
>
<span className="icon">{item.icon}</span>
<span className="label">{item.label}</span>
</Link>
</li>
))}
</ul>
</nav>
<main className="content">
{children}
</main>
</div>
<footer className="footer">
<p>© 2024 Garmin Health Lab. All rights reserved.</p>
</footer>
<main className="content">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,171 @@
.viz {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.1rem 0.9rem;
margin: 0;
box-shadow: var(--shadow);
}
.viz-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.viz-head h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 650;
color: var(--text-primary);
}
.viz-unit {
font-weight: 400;
color: var(--text-muted);
font-size: 0.85em;
}
.viz-sub {
margin: 0.2rem 0 0;
font-size: 0.78rem;
color: var(--text-muted);
}
.viz-toggle {
flex-shrink: 0;
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: 6px;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
cursor: pointer;
font-family: inherit;
}
.viz-toggle:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
.viz-empty {
height: 120px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.85rem;
background: var(--surface-0);
border-radius: 6px;
}
.viz-foot {
margin-top: 0.6rem;
padding-top: 0.6rem;
border-top: 1px solid var(--border);
font-size: 0.78rem;
color: var(--text-muted);
}
/* Identity is never colour alone: the swatch always sits beside a text label. */
.viz-swatch {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.4rem;
vertical-align: baseline;
}
.viz-legend-item {
color: var(--text-secondary);
font-size: 0.78rem;
}
/* Tooltip ------------------------------------------------------------------ */
.viz-tooltip {
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 0.55rem 0.7rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
font-size: 0.8rem;
min-width: 140px;
}
.viz-tooltip-label {
color: var(--text-muted);
font-size: 0.72rem;
margin-bottom: 0.35rem;
}
.viz-tooltip-row {
display: flex;
align-items: center;
gap: 0.1rem;
line-height: 1.7;
}
.viz-tooltip-name {
color: var(--text-secondary);
flex: 1;
margin-right: 0.75rem;
}
/* Values wear text tokens, never the series colour. */
.viz-tooltip-value {
color: var(--text-primary);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Table view --------------------------------------------------------------- */
.viz-table-wrap {
max-height: 260px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 6px;
}
.viz-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.viz-table th,
.viz-table td {
padding: 0.4rem 0.6rem;
text-align: right;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
white-space: nowrap;
}
.viz-table thead th {
position: sticky;
top: 0;
background: var(--surface-2);
color: var(--text-muted);
font-weight: 600;
font-size: 0.74rem;
text-align: right;
}
.viz-table thead th:first-child,
.viz-table tbody th {
text-align: left;
}
.viz-table tbody th {
color: var(--text-primary);
font-weight: 500;
}
.viz-table td {
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}

View File

@@ -0,0 +1,277 @@
import { ReactNode, useState } from 'react';
import {
Area, AreaChart, Bar, BarChart, CartesianGrid, Legend, Line, LineChart,
ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import './Chart.css';
export interface Series {
key: string;
label: string;
/** 1-based slot in the categorical palette. Assigned in order, never cycled. */
slot: 1 | 2 | 3 | 4 | 5 | 6;
unit?: string;
/** Round displayed values to this many decimals. */
decimals?: number;
}
interface ChartProps {
title: string;
subtitle?: string;
data: Array<Record<string, any>>;
series: Series[];
type: 'line' | 'bar' | 'stacked-bar' | 'area';
xKey?: string;
height?: number;
/** Y-axis label; a chart has exactly one axis — never two scales. */
unit?: string;
footer?: ReactNode;
}
const fmt = (value: any, decimals = 0) =>
value == null
? '—'
: typeof value === 'number'
? value.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
: String(value);
function TooltipBox({ active, payload, label, series }: any) {
if (!active || !payload?.length) return null;
return (
<div className="viz-tooltip">
<div className="viz-tooltip-label">{label}</div>
{payload.map((entry: any) => {
const s = series.find((x: Series) => x.key === entry.dataKey);
return (
<div key={entry.dataKey} className="viz-tooltip-row">
<span
className="viz-swatch"
style={{ background: entry.color }}
aria-hidden="true"
/>
<span className="viz-tooltip-name">{s?.label ?? entry.dataKey}</span>
<span className="viz-tooltip-value">
{fmt(entry.value, s?.decimals)}
{s?.unit ? ` ${s.unit}` : ''}
</span>
</div>
);
})}
</div>
);
}
function Chart({
title, subtitle, data, series, type, xKey = 'date', height = 240, unit, footer,
}: ChartProps) {
// A table view is the relief for series whose colour falls below 3:1 on the
// light surface, and doubles as the non-visual reading of any chart.
const [showTable, setShowTable] = useState(false);
const present = series.filter((s) => data.some((row) => row[s.key] != null));
if (present.length === 0) {
return (
<figure className="viz">
<figcaption className="viz-head">
<h4>{title}</h4>
</figcaption>
<div className="viz-empty"></div>
</figure>
);
}
const color = (s: Series) => `var(--series-${s.slot})`;
const axis = {
stroke: 'var(--border-strong)',
tick: { fill: 'var(--text-muted)', fontSize: 11 },
tickLine: false,
};
const margin = { top: 8, right: 8, bottom: 0, left: -8 };
// A legend is mandatory from two series up; a single series is named by the
// title, so a legend box would only repeat it.
const legend =
present.length > 1 ? (
<Legend
verticalAlign="top"
align="left"
height={28}
iconType="circle"
iconSize={8}
formatter={(value: string) => {
const s = present.find((x) => x.key === value);
return <span className="viz-legend-item">{s?.label ?? value}</span>;
}}
/>
) : null;
const grid = <CartesianGrid stroke="var(--grid)" vertical={false} />;
const tip = (
<Tooltip
content={<TooltipBox series={present} />}
cursor={{ stroke: 'var(--border-strong)', strokeWidth: 1 }}
/>
);
const render = () => {
if (type === 'bar' || type === 'stacked-bar') {
const stacked = type === 'stacked-bar';
return (
<BarChart data={data} margin={margin} barCategoryGap="22%">
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} />
{tip}
{legend}
{present.map((s, i) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.key}
fill={color(s)}
stackId={stacked ? 'stack' : undefined}
// 4px rounded data-end on the topmost segment only, so the shape
// reads as one bar anchored to the baseline.
radius={
stacked
? i === present.length - 1
? [4, 4, 0, 0]
: [0, 0, 0, 0]
: [4, 4, 0, 0]
}
// A 2px gap in the surface colour separates adjacent fills.
stroke="var(--surface-1)"
strokeWidth={stacked ? 2 : 0}
isAnimationActive={false}
/>
))}
</BarChart>
);
}
if (type === 'area') {
return (
<AreaChart data={data} margin={margin}>
<defs>
{present.map((s) => (
<linearGradient key={s.key} id={`fill-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color(s)} stopOpacity={0.22} />
<stop offset="100%" stopColor={color(s)} stopOpacity={0.02} />
</linearGradient>
))}
</defs>
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} />
{tip}
{legend}
{present.map((s) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.key}
stroke={color(s)}
strokeWidth={2}
fill={`url(#fill-${s.key})`}
connectNulls
isAnimationActive={false}
/>
))}
</AreaChart>
);
}
return (
<LineChart data={data} margin={margin}>
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} domain={['auto', 'auto']} />
{tip}
{legend}
{present.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.key}
stroke={color(s)}
strokeWidth={2}
dot={false}
// A 2px surface ring keeps overlapping markers readable.
activeDot={{ r: 5, strokeWidth: 2, stroke: 'var(--surface-1)' }}
// Days the device recorded nothing must not fragment the line.
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
);
};
return (
<figure className="viz">
<figcaption className="viz-head">
<div>
<h4>
{title}
{unit && <span className="viz-unit"> ({unit})</span>}
</h4>
{subtitle && <p className="viz-sub">{subtitle}</p>}
</div>
<button
type="button"
className="viz-toggle"
onClick={() => setShowTable((v) => !v)}
aria-pressed={showTable}
>
{showTable ? '看图表' : '看数据'}
</button>
</figcaption>
{showTable ? (
<div className="viz-table-wrap">
<table className="viz-table">
<thead>
<tr>
<th scope="col"></th>
{present.map((s) => (
<th key={s.key} scope="col">
<span
className="viz-swatch"
style={{ background: color(s) }}
aria-hidden="true"
/>
{s.label}
{s.unit ? ` (${s.unit})` : ''}
</th>
))}
</tr>
</thead>
<tbody>
{[...data].reverse().map((row, i) => (
<tr key={`${row[xKey]}-${i}`}>
<th scope="row">{row[xKey]}</th>
{present.map((s) => (
<td key={s.key}>{fmt(row[s.key], s.decimals)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<ResponsiveContainer width="100%" height={height}>
{render()}
</ResponsiveContainer>
)}
{footer && <div className="viz-foot">{footer}</div>}
</figure>
);
}
export default Chart;

View File

@@ -0,0 +1,80 @@
.tile {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem 1rem;
box-shadow: var(--shadow);
}
.tile-label {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.35rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-value {
font-size: 1.55rem;
font-weight: 650;
color: var(--text-primary);
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
.tile-unit {
font-size: 0.72rem;
font-weight: 400;
color: var(--text-muted);
margin-left: 0.25rem;
}
.tile-meter {
height: 4px;
background: var(--surface-0);
border-radius: 999px;
margin-top: 0.55rem;
overflow: hidden;
}
.tile-meter-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
}
.tile-detail {
margin-top: 0.45rem;
font-size: 0.74rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.tile-status {
font-weight: 600;
white-space: nowrap;
}
.status-good { color: var(--status-good); }
.status-warning { color: var(--status-warning); }
.status-serious { color: var(--status-serious); }
.status-critical { color: var(--status-critical); }
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 0.75rem;
}
@media (max-width: 560px) {
.tile-grid {
grid-template-columns: repeat(2, 1fr);
}
.tile-value {
font-size: 1.3rem;
}
}

View File

@@ -0,0 +1,75 @@
import { ReactNode } from 'react';
import './StatTile.css';
export type Status = 'good' | 'warning' | 'serious' | 'critical';
/* Status is carried by an icon plus a label, never by colour alone — the
light-surface status steps are deliberately below 3:1. */
const STATUS_ICON: Record<Status, string> = {
good: '●',
warning: '▲',
serious: '▲',
critical: '■',
};
interface StatTileProps {
label: string;
value: number | string | null | undefined;
unit?: string;
/** Secondary line: a goal, a range, a comparison. */
detail?: ReactNode;
status?: Status;
statusLabel?: string;
/** 01; draws a goal meter under the value. */
progress?: number | null;
}
function StatTile({
label, value, unit, detail, status, statusLabel, progress,
}: StatTileProps) {
const display =
value == null
? '—'
: typeof value === 'number'
? value.toLocaleString(undefined, { maximumFractionDigits: 1 })
: value;
return (
<div className="tile">
<div className="tile-label">{label}</div>
<div className="tile-value">
{display}
{unit && value != null && <span className="tile-unit">{unit}</span>}
</div>
{progress != null && (
<div
className="tile-meter"
role="meter"
aria-valuenow={Math.round(progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${label}完成度`}
>
<div
className="tile-meter-fill"
style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
/>
</div>
)}
{(detail || status) && (
<div className="tile-detail">
{status && (
<span className={`tile-status status-${status}`}>
<span aria-hidden="true">{STATUS_ICON[status]}</span> {statusLabel}
</span>
)}
{detail}
</div>
)}
</div>
);
}
export default StatTile;

View File

@@ -1,24 +1,14 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@import './theme.css';
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
background: var(--surface-0);
color: var(--text-primary);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
html, body, #root {
width: 100%;
height: 100%;
}
html, body, #root { width: 100%; min-height: 100%; }

View File

@@ -0,0 +1,207 @@
import { useEffect, useState } from 'react';
import {
apiClient, Activity, Badge, errorMessage, PersonalRecord,
} from '../services/api';
import StatTile from '../components/charts/StatTile';
import './Pages.css';
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 Achievements() {
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 <div className="page-loading"></div>;
// 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 (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle"></p>
</div>
</header>
{error && <div className="error-message">{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="placeholder"></p>
) : (
years.map((year) => (
<section className="section" key={year}>
<h3 className="section-title">
{year === '未知' ? '未知年份' : `${year}`}
<span className="section-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="placeholder"></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="placeholder"></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>
)
)}
</div>
);
}
export default Achievements;

View File

@@ -1,84 +0,0 @@
.analysis-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.metric-tabs,
.range-tabs {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.metric-tab,
.range-tab {
padding: 0.45rem 0.9rem;
border: 1px solid #ddd;
background: white;
border-radius: 999px;
font-size: 0.9rem;
color: #555;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.metric-tab:hover,
.range-tab:hover {
border-color: #667eea;
color: #667eea;
}
.metric-tab.active,
.range-tab.active {
background: #667eea;
border-color: #667eea;
color: white;
}
.range-tabs {
border-top: 1px solid #f0f0f0;
padding-top: 0.75rem;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.stat-box {
background: #f9f9fb;
border: 1px solid #eee;
border-radius: 8px;
padding: 0.85rem 1rem;
}
.stat-box-label {
font-size: 0.78rem;
color: #999;
margin-bottom: 0.3rem;
}
.stat-box-value {
font-size: 1.15rem;
font-weight: 700;
color: #333;
}
.stat-box-value.up {
color: #2f8f4a;
}
.stat-box-value.down {
color: #c04747;
}
@media (max-width: 600px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -1,159 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { apiClient, errorMessage, TrendPoint } from '../services/api';
import Chart from '../components/Chart';
import './Analysis.css';
const METRICS = [
{ id: 'steps', label: '步数', unit: '步', type: 'bar' as const },
{ id: 'heart_rate', label: '静息心率', unit: 'bpm', type: 'line' as const },
{ id: 'sleep_duration', label: '睡眠时长', unit: '小时', type: 'line' as const },
{ id: 'sleep_quality', label: '睡眠质量', unit: '分', type: 'line' as const },
{ id: 'stress', label: '压力指数', unit: '', type: 'line' as const },
{ id: 'calories_burned', label: '卡路里消耗', unit: 'kcal', type: 'bar' as const },
];
const RANGES = [
{ days: 7, label: '7 天' },
{ days: 30, label: '30 天' },
{ days: 90, label: '90 天' },
{ days: 365, label: '一年' },
];
function stats(points: TrendPoint[]) {
const values = points.map((p) => p.value).filter((v): v is number => v != null);
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mean = values.reduce((a, b) => a + b, 0) / values.length;
// Compare the two halves of the window to describe direction of travel.
const mid = Math.floor(values.length / 2);
const firstHalf = values.slice(0, mid);
const secondHalf = values.slice(mid);
const delta =
firstHalf.length && secondHalf.length
? secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length -
firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
: 0;
return {
count: values.length,
mean,
min: sorted[0],
max: sorted[sorted.length - 1],
median: sorted[Math.floor(sorted.length / 2)],
delta,
};
}
function Analysis() {
const [metric, setMetric] = useState(METRICS[0]);
const [days, setDays] = useState(30);
const [points, setPoints] = useState<TrendPoint[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async (metricId: string, windowDays: number) => {
setLoading(true);
setError('');
try {
const end = new Date();
const start = new Date(end.getTime() - (windowDays - 1) * 86400000);
setPoints(
await apiClient.getTrends(
metricId,
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载趋势失败'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(metric.id, days);
}, [load, metric.id, days]);
const summary = stats(points);
const chartData = points.map((p) => ({ date: p.date.slice(5), value: p.value }));
const fmt = (v: number) => (Math.round(v * 10) / 10).toLocaleString();
return (
<div className="page">
<h2></h2>
<p className="subtitle"></p>
<div className="analysis-controls">
<div className="metric-tabs">
{METRICS.map((m) => (
<button
key={m.id}
className={`metric-tab ${m.id === metric.id ? 'active' : ''}`}
onClick={() => setMetric(m)}
>
{m.label}
</button>
))}
</div>
<div className="range-tabs">
{RANGES.map((r) => (
<button
key={r.days}
className={`range-tab ${r.days === days ? 'active' : ''}`}
onClick={() => setDays(r.days)}
>
{r.label}
</button>
))}
</div>
</div>
{error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>}
{!loading && !error && (
<>
{summary ? (
<div className="stats-row">
<Stat label="平均" value={`${fmt(summary.mean)} ${metric.unit}`} />
<Stat label="中位数" value={`${fmt(summary.median)} ${metric.unit}`} />
<Stat label="最低" value={`${fmt(summary.min)} ${metric.unit}`} />
<Stat label="最高" value={`${fmt(summary.max)} ${metric.unit}`} />
<Stat
label="后半段较前半段"
value={`${summary.delta >= 0 ? '+' : ''}${fmt(summary.delta)} ${metric.unit}`}
tone={summary.delta >= 0 ? 'up' : 'down'}
/>
<Stat label="有效天数" value={`${summary.count}`} />
</div>
) : (
<p className="placeholder"></p>
)}
<Chart
title={`${metric.label}${metric.unit ? ` (${metric.unit})` : ''}`}
data={chartData}
type={metric.type}
dataKey="value"
height={320}
/>
</>
)}
</div>
);
}
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'up' | 'down' }) {
return (
<div className="stat-box">
<div className="stat-box-label">{label}</div>
<div className={`stat-box-value ${tone ?? ''}`}>{value}</div>
</div>
);
}
export default Analysis;

View File

@@ -1,162 +0,0 @@
.today-summary,
.statistics {
margin-bottom: 2rem;
}
.today-summary h3,
.statistics h3,
.charts-section h3 {
font-size: 1.2rem;
color: #333;
margin-bottom: 1rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
display: flex;
align-items: center;
gap: 1rem;
background: white;
border: 1px solid #eee;
border-radius: 8px;
padding: 1rem;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.stat-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
}
.stat-icon {
font-size: 2rem;
flex-shrink: 0;
}
.stat-content {
flex: 1;
}
.stat-label {
font-size: 0.85rem;
color: #999;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.4rem;
font-weight: 700;
color: #333;
}
.charts-section {
margin-top: 2rem;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.chart-container {
background: white;
border: 1px solid #eee;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.chart-container h4 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1rem;
color: #333;
}
.chart-placeholder {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
font-size: 1rem;
background: #f9f9f9;
border-radius: 4px;
}
.page-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
font-size: 1.2rem;
color: #666;
}
.error-message {
background: #fee;
border: 1px solid #fcc;
color: #c33;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.95rem;
}
.placeholder {
text-align: center;
padding: 2rem;
color: #999;
background: #f9f9f9;
border-radius: 8px;
font-size: 1rem;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.charts-grid {
grid-template-columns: 1fr;
}
.stat-value {
font-size: 1.2rem;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.stat-card {
padding: 0.75rem;
}
.stat-icon {
font-size: 1.5rem;
}
.stat-label {
font-size: 0.75rem;
}
.stat-value {
font-size: 1rem;
}
}

View File

@@ -1,52 +1,53 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/Chart';
import './Dashboard.css';
import Chart from '../components/charts/Chart';
import StatTile, { Status } from '../components/charts/StatTile';
import './Pages.css';
/** Flattened row so every chart can address its metric with a plain key. */
interface ChartRow {
date: string;
steps: number | null;
heartRate: number | null;
sleepHours: number | null;
calories: number | null;
const DAYS = 30;
/** MM-DD keeps the axis readable at 30 points. */
const short = (iso: string) => iso.slice(5);
function avg(values: Array<number | null | undefined>): number | null {
const present = values.filter((v): v is number => v != null);
if (!present.length) return null;
return present.reduce((a, b) => a + b, 0) / present.length;
}
function average(values: Array<number | null | undefined>): number {
const present = values.filter((v): v is number => v != null);
if (present.length === 0) return 0;
return present.reduce((a, b) => a + b, 0) / present.length;
/* Thresholds follow the same rules the recommendation engine uses, so the
dashboard and the advice never disagree about what counts as low. */
function sleepStatus(hours: number | null): [Status, string] | [] {
if (hours == null) return [];
if (hours < 6) return ['critical', '偏少'];
if (hours < 7) return ['warning', '略少'];
return ['good', '充足'];
}
function rhrStatus(bpm: number | null): [Status, string] | [] {
if (bpm == null) return [];
if (bpm > 70) return ['serious', '偏高'];
if (bpm > 65) return ['warning', '略高'];
return ['good', '正常'];
}
function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [rows, setRows] = useState<ChartRow[]>([]);
const [today, setToday] = useState<HealthDay | null>(null);
const [days, setDays] = useState<HealthDay[]>([]);
useEffect(() => {
const load = async () => {
try {
const end = new Date();
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000);
const endStr = end.toISOString().slice(0, 10);
const summary = await apiClient.getHealthSummary(
const start = new Date(end.getTime() - (DAYS - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
endStr
end.toISOString().slice(0, 10)
)
);
setRows(
summary.map((d) => ({
date: d.date.slice(5), // MM-DD keeps the axis readable
steps: d.steps,
heartRate: d.heartRate,
sleepHours: d.sleep?.duration ?? null,
calories: d.caloriesBurned,
}))
);
setToday(summary.find((d) => d.date === endStr) ?? null);
} catch (err: any) {
setError(errorMessage(err, '加载数据失败'));
} finally {
@@ -58,83 +59,218 @@ function Dashboard() {
if (loading) return <div className="page-loading"></div>;
const hasData = rows.length > 0;
const stats = {
steps: Math.round(average(rows.map((r) => r.steps))),
heartRate: Math.round(average(rows.map((r) => r.heartRate))),
sleep: Math.round(average(rows.map((r) => r.sleepHours)) * 10) / 10,
calories: Math.round(
rows.reduce((sum, r) => sum + (r.calories ?? 0), 0)
),
};
if (error) {
return (
<div className="page">
<h2></h2>
<div className="error-message">{error}</div>
</div>
);
}
const show = (v: number | null | undefined, suffix = '') =>
v == null ? '—' : `${v.toLocaleString()}${suffix}`;
if (days.length === 0) {
return (
<div className="page">
<h2></h2>
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary"> Garmin </Link>
</div>
</div>
);
}
const today = days[days.length - 1];
const rows = days.map((d) => ({ ...d, date: short(d.date) }));
const avgSteps = avg(days.map((d) => d.steps));
const avgSleep = avg(days.map((d) => d.sleepDuration));
const avgRhr = avg(days.map((d) => d.heartRate));
const avgHrv = avg(days.map((d) => d.heartRateVariability));
const [sleepTone, sleepWord] = sleepStatus(today.sleepDuration);
const [rhrTone, rhrWord] = rhrStatus(today.heartRate);
const round = (v: number | null, d = 0) =>
v == null ? null : Math.round(v * 10 ** d) / 10 ** d;
return (
<div className="page">
<h2></h2>
{error && <div className="error-message">{error}</div>}
{!hasData && !error && (
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary">
Garmin
</Link>
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle">{today.date} · {days.length} </p>
</div>
)}
<Link to="/trends" className="btn btn-plain"> </Link>
</header>
{hasData && (
<>
<section className="today-summary">
<h3></h3>
{today ? (
<div className="stats-grid">
<StatCard icon="🚶" label="步数" value={show(today.steps)} />
<StatCard icon="❤️" label="静息心率" value={show(today.heartRate, ' bpm')} />
<StatCard icon="😴" label="睡眠" value={show(today.sleep?.duration, ' 小时')} />
<StatCard icon="🔥" label="消耗" value={show(today.caloriesBurned, ' kcal')} />
</div>
) : (
<p className="placeholder"></p>
{/* Activity ---------------------------------------------------------- */}
<section className="section">
<h3 className="section-title"></h3>
<div className="tile-grid">
<StatTile
label="步数"
value={today.steps}
detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined}
progress={
today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null
}
/>
<StatTile
label="距离"
value={round(today.distanceMeters != null ? today.distanceMeters / 1000 : null, 2)}
unit="km"
/>
<StatTile label="爬楼" value={round(today.floorsAscended)} unit="层" />
<StatTile
label="强度分钟"
value={today.intensityMinutes}
unit="分钟"
detail="中等以上强度"
/>
<StatTile
label="总消耗"
value={round(today.caloriesBurned)}
unit="kcal"
detail={
today.activeCalories != null
? `其中活动 ${Math.round(today.activeCalories)}`
: undefined
}
/>
<StatTile
label="久坐"
value={round(
today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null, 1
)}
</section>
<section className="statistics">
<h3> 30 </h3>
<div className="stats-grid">
<StatCard icon="📈" label="日均步数" value={show(stats.steps)} />
<StatCard icon="❤️" label="平均静息心率" value={show(stats.heartRate, ' bpm')} />
<StatCard icon="😴" label="日均睡眠" value={show(stats.sleep, ' 小时')} />
<StatCard icon="🔥" label="累计消耗" value={show(stats.calories, ' kcal')} />
unit="小时"
/>
</div>
</section>
<section className="charts-section">
<h3></h3>
<div className="charts-grid">
<Chart title="步数" data={rows} type="bar" dataKey="steps" fill="#667eea" />
<Chart title="静息心率 (bpm)" data={rows} type="line" dataKey="heartRate" stroke="#ff6b9d" />
<Chart title="睡眠时长 (小时)" data={rows} type="line" dataKey="sleepHours" stroke="#f0a500" />
<Chart title="卡路里消耗" data={rows} type="bar" dataKey="calories" fill="#4caf50" />
{/* Heart & stress ---------------------------------------------------- */}
<section className="section">
<h3 className="section-title"></h3>
<div className="tile-grid">
<StatTile
label="静息心率"
value={today.heartRate}
unit="bpm"
status={rhrTone}
statusLabel={rhrWord}
detail={avgRhr != null ? `30 日均 ${Math.round(avgRhr)}` : undefined}
/>
<StatTile
label="心率区间"
value={
today.heartRateMin != null && today.heartRateMax != null
? `${today.heartRateMin}${today.heartRateMax}`
: null
}
unit="bpm"
/>
<StatTile
label="心率变异性"
value={round(today.heartRateVariability)}
unit="ms"
detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
/>
<StatTile
label="平均压力"
value={today.stress}
detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
/>
<StatTile
label="身体电量"
value={
today.bodyBatteryLow != null && today.bodyBatteryHigh != null
? `${today.bodyBatteryLow}${today.bodyBatteryHigh}`
: null
}
detail={
today.bodyBatteryCharged != null
? `${today.bodyBatteryCharged} / 耗 ${today.bodyBatteryDrained}`
: undefined
}
/>
<StatTile label="训练准备度" value={today.trainingReadiness} unit="/100" />
</div>
</section>
</>
)}
</div>
);
}
function StatCard({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-content">
<div className="stat-label">{label}</div>
<div className="stat-value">{value}</div>
{/* Sleep & breathing -------------------------------------------------- */}
<section className="section">
<h3 className="section-title"></h3>
<div className="tile-grid">
<StatTile
label="睡眠时长"
value={today.sleepDuration}
unit="小时"
status={sleepTone}
statusLabel={sleepWord}
detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
/>
<StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" />
<StatTile
label="血氧"
value={round(today.spo2Avg)}
unit="%"
detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
/>
<StatTile
label="呼吸频率"
value={round(today.respirationAvg)}
unit="次/分"
detail={
today.respirationMin != null && today.respirationMax != null
? `${today.respirationMin}${today.respirationMax}`
: undefined
}
/>
</div>
<p className="section-link">
<Link to="/sleep"> </Link>
</p>
</section>
{/* Trends ------------------------------------------------------------- */}
<section className="section">
<h3 className="section-title"> {days.length} </h3>
<div className="chart-grid">
<Chart
title="步数"
subtitle={avgSteps != null ? `日均 ${Math.round(avgSteps).toLocaleString()}` : undefined}
data={rows}
type="bar"
series={[{ key: 'steps', label: '步数', slot: 1, unit: '步' }]}
/>
<Chart
title="心率"
unit="bpm"
data={rows}
type="line"
series={[
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
]}
/>
<Chart
title="睡眠时长"
unit="小时"
data={rows}
type="area"
series={[{ key: 'sleepDuration', label: '睡眠', slot: 1, unit: '小时', decimals: 1 }]}
/>
<Chart
title="身体电量"
data={rows}
type="line"
series={[
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
]}
/>
</div>
</section>
</div>
);
}

View File

@@ -1,59 +1,351 @@
.page {
animation: fadeIn 0.3s ease-in;
animation: fadeIn 0.25s ease-out;
color: var(--text-primary);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.page h2 {
color: #333;
margin-bottom: 1.5rem;
font-size: 1.8rem;
margin: 0;
font-size: 1.4rem;
font-weight: 680;
color: var(--text-primary);
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--text-muted);
font-size: 0.85rem;
}
.section {
margin-bottom: 1.75rem;
}
.section-title {
display: flex;
align-items: baseline;
gap: 0.6rem;
font-size: 0.8rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 0 0 0.7rem;
}
.section-count {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.section-link {
margin: 0.7rem 0 0;
font-size: 0.82rem;
}
.section-link a,
.page a {
color: var(--accent);
text-decoration: none;
}
.section-link a:hover,
.page a:hover {
text-decoration: underline;
}
/* Filters sit in one row above the charts. */
.range-tabs,
.metric-tabs {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.metric-tabs {
margin-bottom: 1.25rem;
padding-bottom: 0.9rem;
border-bottom: 1px solid var(--border);
}
.range-tab,
.metric-tab {
padding: 0.34rem 0.8rem;
border: 1px solid var(--border);
background: var(--surface-1);
border-radius: 999px;
font-size: 0.82rem;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
font-family: inherit;
white-space: nowrap;
}
.range-tab:hover,
.metric-tab:hover {
border-color: var(--accent);
color: var(--accent);
}
.range-tab.active,
.metric-tab.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.chart-grid.one-col {
grid-template-columns: 1fr;
}
.page-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
font-size: 1.2rem;
color: #666;
min-height: 240px;
color: var(--text-muted);
font-size: 0.95rem;
}
.placeholder {
color: #999;
font-size: 1.1rem;
padding: 2rem;
background-color: #f9f9f9;
border-radius: 8px;
text-align: center;
padding: 2rem;
color: var(--text-muted);
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 3rem 2rem;
background: var(--surface-1);
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
color: var(--text-secondary);
}
.empty-state p {
margin: 0 0 1.1rem;
}
.error-message {
background: color-mix(in srgb, var(--status-critical) 10%, var(--surface-1));
border: 1px solid color-mix(in srgb, var(--status-critical) 35%, transparent);
color: var(--status-critical);
padding: 0.85rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.88rem;
}
.success-message {
background: color-mix(in srgb, var(--status-good) 10%, var(--surface-1));
border: 1px solid color-mix(in srgb, var(--status-good) 35%, transparent);
color: var(--text-primary);
padding: 0.85rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.88rem;
}
/* Buttons ------------------------------------------------------------------ */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
padding: 0.55rem 1.1rem;
border: 1px solid transparent;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1rem;
font-family: inherit;
transition: all 0.15s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #667eea;
color: white;
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background-color: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-large {
width: 100%;
max-width: 300px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-plain {
background: var(--surface-1);
border-color: var(--border);
color: var(--text-secondary);
font-size: 0.84rem;
padding: 0.4rem 0.85rem;
}
.btn-plain:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
/* Badges ------------------------------------------------------------------- */
.badge-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.65rem;
}
.badge {
background: var(--surface-1);
border: 1px solid var(--border);
border-left: 3px solid var(--series-4);
border-radius: 8px;
padding: 0.7rem 0.85rem;
}
.badge-name {
font-size: 0.86rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.4;
}
.badge-meta {
margin-top: 0.3rem;
font-size: 0.74rem;
color: var(--text-muted);
display: flex;
gap: 0.5rem;
}
.badge-count {
color: var(--accent);
font-weight: 600;
}
/* Tables ------------------------------------------------------------------- */
.table-wrap {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: auto;
background: var(--surface-1);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--border);
text-align: left;
color: var(--text-secondary);
white-space: nowrap;
}
.data-table thead th {
background: var(--surface-2);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky;
top: 0;
}
.data-table tbody th {
color: var(--text-primary);
font-weight: 550;
}
.data-table td.num {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.data-table tbody tr:last-child th,
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* Forms -------------------------------------------------------------------- */
.form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1rem;
}
.form-group label {
font-size: 0.84rem;
font-weight: 600;
color: var(--text-secondary);
}
.form-group input {
padding: 0.65rem 0.8rem;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.95rem;
font-family: inherit;
background: var(--surface-2);
color: var(--text-primary);
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.field-hint {
font-size: 0.78rem;
color: var(--text-muted);
line-height: 1.7;
margin: 0;
}
@media (max-width: 640px) {
.chart-grid {
grid-template-columns: 1fr;
}
.page-head {
flex-direction: column;
}
}

175
client/src/pages/Sleep.tsx Normal file
View File

@@ -0,0 +1,175 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/charts/Chart';
import StatTile from '../components/charts/StatTile';
import './Pages.css';
const RANGES = [7, 14, 30, 90];
const H = 3600;
function avg(values: Array<number | null | undefined>): number | null {
const present = values.filter((v): v is number => v != null);
return present.length ? present.reduce((a, b) => a + b, 0) / present.length : null;
}
function Sleep() {
const [days, setDays] = useState<HealthDay[]>([]);
const [range, setRange] = useState(30);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const end = new Date();
const start = new Date(end.getTime() - (range - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载睡眠数据失败'));
} finally {
setLoading(false);
}
};
load();
}, [range]);
const nights = days.filter((d) => d.sleepDuration != null);
// Stage seconds are converted to hours here so the stacked bar and the
// duration chart share one y-scale — a chart never carries two scales.
const rows = nights.map((d) => ({
date: d.date.slice(5),
deep: d.sleep?.deepSeconds != null ? d.sleep.deepSeconds / H : null,
light: d.sleep?.lightSeconds != null ? d.sleep.lightSeconds / H : null,
rem: d.sleep?.remSeconds != null ? d.sleep.remSeconds / H : null,
awake: d.sleep?.awakeSeconds != null ? d.sleep.awakeSeconds / H : null,
quality: d.sleepQuality,
spo2: d.sleepSpo2Avg,
respiration: d.sleepRespirationAvg,
stress: d.sleepStressAvg,
}));
const avgDeep = avg(rows.map((r) => r.deep));
const avgRem = avg(rows.map((r) => r.rem));
const avgLight = avg(rows.map((r) => r.light));
const avgAwake = avg(rows.map((r) => r.awake));
const avgDuration = avg(nights.map((d) => d.sleepDuration));
const avgQuality = avg(nights.map((d) => d.sleepQuality));
const totalStages = [avgDeep, avgLight, avgRem].reduce<number>(
(sum, v) => sum + (v ?? 0), 0
);
const share = (v: number | null) =>
v == null || totalStages === 0 ? undefined : `${Math.round((v / totalStages) * 100)}%`;
const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
return (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle"></p>
</div>
<div className="range-tabs">
{RANGES.map((r) => (
<button
key={r}
className={`range-tab ${r === range ? 'active' : ''}`}
onClick={() => setRange(r)}
>
{r}
</button>
))}
</div>
</header>
{error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>}
{!loading && !error && nights.length === 0 && (
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary"></Link>
</div>
)}
{!loading && !error && nights.length > 0 && (
<>
<section className="section">
<h3 className="section-title">{nights.length} </h3>
<div className="tile-grid">
<StatTile label="总时长" value={hrs(avgDuration)} unit="小时" />
<StatTile label="睡眠评分" value={hrs(avgQuality, 0)} unit="/100" />
<StatTile label="深睡" value={hrs(avgDeep)} unit="小时" detail={share(avgDeep)} />
<StatTile label="浅睡" value={hrs(avgLight)} unit="小时" detail={share(avgLight)} />
<StatTile label="REM" value={hrs(avgRem)} unit="小时" detail={share(avgRem)} />
<StatTile label="夜间清醒" value={hrs(avgAwake)} unit="小时" />
</div>
</section>
<section className="section">
<div className="chart-grid one-col">
<Chart
title="睡眠分期"
unit="小时"
subtitle="每晚各阶段时长堆叠;总高度即当晚睡眠总时长"
data={rows}
type="stacked-bar"
height={280}
series={[
{ key: 'deep', label: '深睡', slot: 1, unit: '小时', decimals: 1 },
{ key: 'light', label: '浅睡', slot: 2, unit: '小时', decimals: 1 },
{ key: 'rem', label: 'REM', slot: 3, unit: '小时', decimals: 1 },
{ key: 'awake', label: '清醒', slot: 4, unit: '小时', decimals: 1 },
]}
footer="成人参考:深睡约占 1323%REM 约占 2025%。"
/>
</div>
<div className="chart-grid">
<Chart
title="睡眠评分"
unit="/100"
data={rows}
type="area"
series={[{ key: 'quality', label: '评分', slot: 1 }]}
/>
<Chart
title="夜间血氧"
unit="%"
data={rows}
type="line"
series={[{ key: 'spo2', label: '血氧', slot: 1, unit: '%', decimals: 1 }]}
/>
<Chart
title="夜间呼吸频率"
unit="次/分"
data={rows}
type="line"
series={[
{ key: 'respiration', label: '呼吸', slot: 1, unit: '次/分', decimals: 1 },
]}
/>
<Chart
title="睡眠压力"
data={rows}
type="line"
series={[{ key: 'stress', label: '压力', slot: 1, decimals: 1 }]}
/>
</div>
</section>
</>
)}
</div>
);
}
export default Sleep;

257
client/src/pages/Trends.tsx Normal file
View File

@@ -0,0 +1,257 @@
import { useEffect, useMemo, useState } from 'react';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart, { Series } from '../components/charts/Chart';
import StatTile from '../components/charts/StatTile';
import './Pages.css';
const RANGES = [7, 14, 30, 90, 365];
/** Each group is one chart. Metrics only share a chart when they share a
* scale and a unit — a chart never carries two y-scales. */
interface MetricGroup {
id: string;
label: string;
unit?: string;
type: 'line' | 'bar' | 'area';
/** Which HealthDay fields to plot, in palette-slot order. */
series: Series[];
/** Optional transform, e.g. seconds to hours. */
scale?: Record<string, number>;
note?: string;
}
const GROUPS: MetricGroup[] = [
{
id: 'steps', label: '步数', unit: '步', type: 'bar',
series: [{ key: 'steps', label: '步数', slot: 1, unit: '步' }],
},
{
id: 'distance', label: '距离', unit: 'km', type: 'bar',
scale: { distanceMeters: 1 / 1000 },
series: [{ key: 'distanceMeters', label: '距离', slot: 1, unit: 'km', decimals: 2 }],
},
{
id: 'calories', label: '能量消耗', unit: 'kcal', type: 'bar',
series: [
{ key: 'bmrCalories', label: '基础代谢', slot: 1, unit: 'kcal' },
{ key: 'activeCalories', label: '活动消耗', slot: 2, unit: 'kcal' },
],
note: '两者相加即当日总消耗。',
},
{
id: 'heart', label: '心率', unit: 'bpm', type: 'line',
series: [
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
{ key: 'heartRateMin', label: '最低', slot: 3, unit: 'bpm' },
],
},
{
id: 'hrv', label: '心率变异性', unit: 'ms', type: 'area',
series: [{ key: 'heartRateVariability', label: 'HRV', slot: 1, unit: 'ms', decimals: 1 }],
note: 'HRV 反映自主神经恢复情况,持续偏低常与压力或训练过量相关。',
},
{
id: 'stress', label: '压力', type: 'line',
series: [
{ key: 'stress', label: '平均', slot: 1 },
{ key: 'stressMax', label: '峰值', slot: 2 },
],
},
{
id: 'battery', label: '身体电量', type: 'line',
series: [
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
],
},
{
id: 'sleep', label: '睡眠时长', unit: '小时', type: 'area',
series: [{ key: 'sleepDuration', label: '时长', slot: 1, unit: '小时', decimals: 1 }],
},
{
id: 'spo2', label: '血氧', unit: '%', type: 'line',
series: [
{ key: 'spo2Avg', label: '平均', slot: 1, unit: '%', decimals: 1 },
{ key: 'spo2Min', label: '最低', slot: 2, unit: '%' },
],
},
{
id: 'respiration', label: '呼吸频率', unit: '次/分', type: 'line',
series: [
{ key: 'respirationAvg', label: '平均', slot: 1, unit: '次/分', decimals: 1 },
{ key: 'respirationMax', label: '最高', slot: 2, unit: '次/分', decimals: 1 },
{ key: 'respirationMin', label: '最低', slot: 3, unit: '次/分', decimals: 1 },
],
},
{
id: 'floors', label: '爬楼', unit: '层', type: 'bar',
series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层', decimals: 0 }],
},
{
id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
series: [{ key: 'intensityMinutes', label: '强度分钟', slot: 1, unit: '分钟' }],
},
{
id: 'sedentary', label: '久坐与活动时长', unit: '小时', type: 'bar',
scale: { sedentarySeconds: 1 / 3600, activeSeconds: 1 / 3600 },
series: [
{ key: 'sedentarySeconds', label: '久坐', slot: 1, unit: '小时', decimals: 1 },
{ key: 'activeSeconds', label: '活动', slot: 2, unit: '小时', decimals: 1 },
],
},
{
id: 'training', label: '训练准备度', unit: '/100', type: 'area',
series: [{ key: 'trainingReadiness', label: '准备度', slot: 1 }],
},
{
id: 'endurance', label: '耐力分', type: 'area',
series: [{ key: 'enduranceScore', label: '耐力分', slot: 1 }],
},
];
function stats(values: Array<number | null | undefined>) {
const present = values.filter((v): v is number => v != null);
if (!present.length) return null;
const sorted = [...present].sort((a, b) => a - b);
const mean = present.reduce((a, b) => a + b, 0) / present.length;
const mid = Math.floor(present.length / 2);
const firstHalf = present.slice(0, mid);
const secondHalf = present.slice(mid);
const delta =
firstHalf.length && secondHalf.length
? secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length -
firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
: 0;
return {
count: present.length,
mean,
min: sorted[0],
max: sorted[sorted.length - 1],
median: sorted[mid],
delta,
};
}
function Trends() {
const [days, setDays] = useState<HealthDay[]>([]);
const [range, setRange] = useState(30);
const [active, setActive] = useState(GROUPS[0].id);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
try {
const end = new Date();
const start = new Date(end.getTime() - (range - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
}, [range]);
const group = GROUPS.find((g) => g.id === active) ?? GROUPS[0];
const rows = useMemo(
() =>
days.map((d) => {
const row: Record<string, any> = { date: d.date.slice(5) };
for (const s of group.series) {
const raw = (d as any)[s.key];
const factor = group.scale?.[s.key];
row[s.key] = raw == null ? null : factor ? raw * factor : raw;
}
return row;
}),
[days, group]
);
const primary = group.series[0];
const summary = stats(rows.map((r) => r[primary.key]));
const fmt = (v: number) =>
(Math.round(v * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 });
return (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle"> {GROUPS.length} </p>
</div>
<div className="range-tabs">
{RANGES.map((r) => (
<button
key={r}
className={`range-tab ${r === range ? 'active' : ''}`}
onClick={() => setRange(r)}
>
{r === 365 ? '一年' : `${r}`}
</button>
))}
</div>
</header>
<div className="metric-tabs">
{GROUPS.map((g) => (
<button
key={g.id}
className={`metric-tab ${g.id === active ? 'active' : ''}`}
onClick={() => setActive(g.id)}
>
{g.label}
</button>
))}
</div>
{error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>}
{!loading && !error && (
<>
{summary ? (
<div className="tile-grid" style={{ marginBottom: '1.25rem' }}>
<StatTile label="平均" value={fmt(summary.mean)} unit={primary.unit} />
<StatTile label="中位数" value={fmt(summary.median)} unit={primary.unit} />
<StatTile label="最低" value={fmt(summary.min)} unit={primary.unit} />
<StatTile label="最高" value={fmt(summary.max)} unit={primary.unit} />
<StatTile
label="后半段对比前半段"
value={`${summary.delta >= 0 ? '+' : ''}${fmt(summary.delta)}`}
unit={primary.unit}
/>
<StatTile label="有效天数" value={summary.count} unit="天" />
</div>
) : (
<p className="placeholder"></p>
)}
<div className="chart-grid one-col">
<Chart
title={group.label}
unit={group.unit}
data={rows}
type={group.type}
series={group.series}
height={320}
footer={group.note}
/>
</div>
</>
)}
</div>
);
}
export default Trends;

View File

@@ -12,14 +12,87 @@ export interface AuthResponse {
token: string;
}
export interface SleepDetail {
duration: number;
quality: number | null;
deepSeconds: number | null;
lightSeconds: number | null;
remSeconds: number | null;
awakeSeconds: number | null;
}
/** One day, with every metric the sync stores. All are nullable: a device
* that does not record a metric leaves it null rather than zero. */
export interface HealthDay {
date: string;
steps: number | null;
heartRate: number | null;
heartRateVariability: number | null;
sleep: { duration: number; quality: number | null } | null;
stress: number | null;
stepGoal: number | null;
distanceMeters: number | null;
caloriesBurned: number | null;
activeCalories: number | null;
bmrCalories: number | null;
floorsAscended: number | null;
floorsDescended: number | null;
intensityMinutes: number | null;
sedentarySeconds: number | null;
activeSeconds: number | null;
heartRate: number | null;
heartRateMax: number | null;
heartRateMin: number | null;
heartRateVariability: number | null;
stress: number | null;
stressMax: number | null;
bodyBatteryHigh: number | null;
bodyBatteryLow: number | null;
bodyBatteryCharged: number | null;
bodyBatteryDrained: number | null;
spo2Avg: number | null;
spo2Min: number | null;
respirationAvg: number | null;
respirationMin: number | null;
respirationMax: number | null;
sleepDuration: number | null;
sleepQuality: number | null;
sleepSpo2Avg: number | null;
sleepRespirationAvg: number | null;
sleepStressAvg: number | null;
trainingReadiness: number | null;
vo2max: number | null;
enduranceScore: number | null;
sleep: SleepDetail | null;
}
export interface Badge {
id: string;
badge_key: string | null;
name: string | null;
category_id: number | null;
difficulty_id: number | null;
earned_date: string | null;
earned_count: number | null;
points: number | null;
}
export interface PersonalRecord {
id: string;
type_id: number | null;
activity_id: string | null;
activity_name: string | null;
activity_type: string | null;
value: number | null;
achieved_at: string | null;
}
export interface Activity {
id: string;
activity_type: string;
start_time: string;
end_time: string;
duration: number | null;
distance: number | null;
calories: number | null;
heart_rate_average: number | null;
heart_rate_max: number | null;
}
export interface SyncStatus {
@@ -266,13 +339,25 @@ class ApiClient {
}
async getActivities(startDate?: string, endDate?: string) {
const { data } = await this.client.get<any[]>(
const { data } = await this.client.get<Activity[]>(
'/health/activities',
this.range(startDate, endDate)
);
return data;
}
async getBadges() {
const { data } = await this.client.get<Badge[]>('/health/badges');
return data;
}
async getPersonalRecords() {
const { data } = await this.client.get<PersonalRecord[]>(
'/health/personal-records'
);
return data;
}
// --- analysis ---
async getTrends(metricType: string, startDate?: string, endDate?: string) {
const { data } = await this.client.get<TrendPoint[]>('/analysis/trends', {

102
client/src/theme.css Normal file
View File

@@ -0,0 +1,102 @@
/*
* Design tokens.
*
* The series colours are the validated categorical palette, in its documented
* slot order — the ordering is the colour-blind-safety mechanism, not a
* cosmetic choice, so slots are assigned in order and never cycled.
* Verified with the palette validator in both modes:
* light worst adjacent CVD ΔE 9.1, normal-vision ΔE 22.9
* dark worst adjacent CVD ΔE 8.4, normal-vision ΔE 19.8
* On the light surface aqua (2.74:1) and yellow (2.11:1) fall below 3:1, so
* every chart using them ships visible labels plus a table view.
*/
:root {
color-scheme: light;
/* surfaces & ink */
--surface-0: #f4f4f2;
--surface-1: #fcfcfb;
--surface-2: #ffffff;
--border: #e4e3df;
--border-strong: #d3d2cd;
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #86847e;
/* categorical series — assign in order */
--series-1: #2a78d6; /* blue */
--series-2: #eb6834; /* orange */
--series-3: #1baf7a; /* aqua */
--series-4: #eda100; /* yellow */
--series-5: #e87ba4; /* magenta */
--series-6: #008300; /* green */
/* status — reserved, never reused as a series */
--status-good: #0ca30c;
--status-warning: #fab219;
--status-serious: #ec835a;
--status-critical: #d03b3b;
--grid: #eceae5;
--accent: #2a78d6;
--accent-soft: #eef4fd;
--radius: 10px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 8px rgba(0, 0, 0, 0.03);
}
/* Dark steps are the same hues re-stepped for the dark surface — selected and
validated as a set, not an automatic inversion. Declared under both the OS
media query and the explicit toggle so either can win. */
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme='light'])) {
color-scheme: dark;
--surface-0: #131312;
--surface-1: #1a1a19;
--surface-2: #232322;
--border: #34342f;
--border-strong: #45443e;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #8f8e85;
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--grid: #2c2c28;
--accent: #3987e5;
--accent-soft: #1d2938;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
}
:root[data-theme='dark'] {
color-scheme: dark;
--surface-0: #131312;
--surface-1: #1a1a19;
--surface-2: #232322;
--border: #34342f;
--border-strong: #45443e;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #8f8e85;
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--grid: #2c2c28;
--accent: #3987e5;
--accent-soft: #1d2938;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}