[阶段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:
@@ -2,9 +2,9 @@
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"name": "client",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"runtimeArgs": ["start", "--workspace=client"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
171
client/src/components/charts/Chart.css
Normal file
171
client/src/components/charts/Chart.css
Normal 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);
|
||||
}
|
||||
277
client/src/components/charts/Chart.tsx
Normal file
277
client/src/components/charts/Chart.tsx
Normal 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;
|
||||
80
client/src/components/charts/StatTile.css
Normal file
80
client/src/components/charts/StatTile.css
Normal 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;
|
||||
}
|
||||
}
|
||||
75
client/src/components/charts/StatTile.tsx
Normal file
75
client/src/components/charts/StatTile.tsx
Normal 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;
|
||||
/** 0–1; 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;
|
||||
@@ -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%; }
|
||||
|
||||
207
client/src/pages/Achievements.tsx
Normal file
207
client/src/pages/Achievements.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,85 +59,220 @@ 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)
|
||||
),
|
||||
};
|
||||
|
||||
const show = (v: number | null | undefined, suffix = '') =>
|
||||
v == null ? '—' : `${v.toLocaleString()}${suffix}`;
|
||||
|
||||
if (error) {
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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')} />
|
||||
</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" />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
<h2>今日概览</h2>
|
||||
<div className="error-message">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: string; label: string; value: string }) {
|
||||
if (days.length === 0) {
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{/* 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
|
||||
)}
|
||||
unit="小时"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -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
175
client/src/pages/Sleep.tsx
Normal 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="成人参考:深睡约占 13–23%,REM 约占 20–25%。"
|
||||
/>
|
||||
</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
257
client/src/pages/Trends.tsx
Normal 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;
|
||||
@@ -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
102
client/src/theme.css
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user