From ad88ec7e412445577ae96ba8f8d1e1880cd90fe0 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 21:17:08 +0800 Subject: [PATCH] =?UTF-8?q?[=E9=98=B6=E6=AE=B57]=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=87=8D=E5=81=9A=EF=BC=9A31=20=E9=A1=B9=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E9=9C=B2=E5=87=BA=EF=BC=8C=E9=85=8D=E8=89=B2?= =?UTF-8?q?=E7=BB=8F=E8=89=B2=E7=9B=B2=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来仪表板只有 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 并标在 - 导航改为顶部横向,移动端可横滑 - 表格、徽章、空状态等组件统一到设计令牌 验证方式: DOM 审计确认 2px 线宽、圆角为 A 4,4 弧、堆叠段 2px 间隙、图例数量与系列数匹配、两档主题令牌各自解析到校验过的色值。 Co-Authored-By: Claude Haiku 4.5 --- .claude/launch.json | 4 +- client/src/App.tsx | 8 +- client/src/components/Chart.tsx | 76 ----- client/src/components/Layout.css | 148 ++++----- client/src/components/Layout.tsx | 102 ++++--- client/src/components/charts/Chart.css | 171 +++++++++++ client/src/components/charts/Chart.tsx | 277 +++++++++++++++++ client/src/components/charts/StatTile.css | 80 +++++ client/src/components/charts/StatTile.tsx | 75 +++++ client/src/index.css | 26 +- client/src/pages/Achievements.tsx | 207 +++++++++++++ client/src/pages/Analysis.css | 84 ----- client/src/pages/Analysis.tsx | 159 ---------- client/src/pages/Dashboard.css | 162 ---------- client/src/pages/Dashboard.tsx | 334 ++++++++++++++------ client/src/pages/Pages.css | 356 ++++++++++++++++++++-- client/src/pages/Sleep.tsx | 175 +++++++++++ client/src/pages/Trends.tsx | 257 ++++++++++++++++ client/src/services/api.ts | 95 +++++- client/src/theme.css | 102 +++++++ 20 files changed, 2131 insertions(+), 767 deletions(-) delete mode 100644 client/src/components/Chart.tsx create mode 100644 client/src/components/charts/Chart.css create mode 100644 client/src/components/charts/Chart.tsx create mode 100644 client/src/components/charts/StatTile.css create mode 100644 client/src/components/charts/StatTile.tsx create mode 100644 client/src/pages/Achievements.tsx delete mode 100644 client/src/pages/Analysis.css delete mode 100644 client/src/pages/Analysis.tsx delete mode 100644 client/src/pages/Dashboard.css create mode 100644 client/src/pages/Sleep.tsx create mode 100644 client/src/pages/Trends.tsx create mode 100644 client/src/theme.css diff --git a/.claude/launch.json b/.claude/launch.json index 04ffa28..602e0ba 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -2,9 +2,9 @@ "version": "0.0.1", "configurations": [ { - "name": "dev", + "name": "client", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], + "runtimeArgs": ["start", "--workspace=client"], "port": 3000 } ] diff --git a/client/src/App.tsx b/client/src/App.tsx index b796fea..79de743 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> } /> - } /> + } /> + } /> + } /> } /> } /> diff --git a/client/src/components/Chart.tsx b/client/src/components/Chart.tsx deleted file mode 100644 index 6e6519f..0000000 --- a/client/src/components/Chart.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { - LineChart, Line, BarChart, Bar, XAxis, YAxis, - CartesianGrid, Tooltip, ResponsiveContainer, -} from 'recharts'; - -interface ChartProps { - title: string; - data: Array>; - 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 ( -
-

{title}

-
暂无数据
-
- ); - } - - const axisProps = { fontSize: 12, stroke: '#999' }; - - return ( -
-

{title}

- - {type === 'line' ? ( - - - - - - {/* connectNulls keeps the line continuous across days the device - did not record, instead of breaking it into fragments. */} - - - ) : ( - - - - - - - - )} - -
- ); -} - -export default Chart; diff --git a/client/src/components/Layout.css b/client/src/components/Layout.css index 4341ed2..94aeba9 100644 --- a/client/src/components/Layout.css +++ b/client/src/components/Layout.css @@ -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; } } diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 72e2c39..7e24575 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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 and the tokens swap in one place. */ +function useTheme(): [Theme, (t: Theme) => void] { + const [theme, setTheme] = useState( + () => (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 (
-
-

🏃 Garmin Health Lab

-

佳明健康数据分析平台

+
+ Garmin Health Lab + + + +
-
- - -
- {children} -
-
- -
-

© 2024 Garmin Health Lab. All rights reserved.

-
+
{children}
); } diff --git a/client/src/components/charts/Chart.css b/client/src/components/charts/Chart.css new file mode 100644 index 0000000..92614f8 --- /dev/null +++ b/client/src/components/charts/Chart.css @@ -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); +} diff --git a/client/src/components/charts/Chart.tsx b/client/src/components/charts/Chart.tsx new file mode 100644 index 0000000..cafbc49 --- /dev/null +++ b/client/src/components/charts/Chart.tsx @@ -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>; + 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 ( +
+
{label}
+ {payload.map((entry: any) => { + const s = series.find((x: Series) => x.key === entry.dataKey); + return ( +
+
+ ); + })} +
+ ); +} + +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 ( +
+
+

{title}

+
+
暂无数据
+
+ ); + } + + 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 ? ( + { + const s = present.find((x) => x.key === value); + return {s?.label ?? value}; + }} + /> + ) : null; + + const grid = ; + const tip = ( + } + cursor={{ stroke: 'var(--border-strong)', strokeWidth: 1 }} + /> + ); + + const render = () => { + if (type === 'bar' || type === 'stacked-bar') { + const stacked = type === 'stacked-bar'; + return ( + + {grid} + + + {tip} + {legend} + {present.map((s, i) => ( + + ))} + + ); + } + + if (type === 'area') { + return ( + + + {present.map((s) => ( + + + + + ))} + + {grid} + + + {tip} + {legend} + {present.map((s) => ( + + ))} + + ); + } + + return ( + + {grid} + + + {tip} + {legend} + {present.map((s) => ( + + ))} + + ); + }; + + return ( +
+
+
+

+ {title} + {unit && ({unit})} +

+ {subtitle &&

{subtitle}

} +
+ +
+ + {showTable ? ( +
+ + + + + {present.map((s) => ( + + ))} + + + + {[...data].reverse().map((row, i) => ( + + + {present.map((s) => ( + + ))} + + ))} + +
日期 +
{row[xKey]}{fmt(row[s.key], s.decimals)}
+
+ ) : ( + + {render()} + + )} + + {footer &&
{footer}
} +
+ ); +} + +export default Chart; diff --git a/client/src/components/charts/StatTile.css b/client/src/components/charts/StatTile.css new file mode 100644 index 0000000..7de706d --- /dev/null +++ b/client/src/components/charts/StatTile.css @@ -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; + } +} diff --git a/client/src/components/charts/StatTile.tsx b/client/src/components/charts/StatTile.tsx new file mode 100644 index 0000000..bdf08bc --- /dev/null +++ b/client/src/components/charts/StatTile.tsx @@ -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 = { + 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 ( +
+
{label}
+
+ {display} + {unit && value != null && {unit}} +
+ + {progress != null && ( +
+
+
+ )} + + {(detail || status) && ( +
+ {status && ( + + {statusLabel} + + )} + {detail} +
+ )} +
+ ); +} + +export default StatTile; diff --git a/client/src/index.css b/client/src/index.css index eb3addf..4f44565 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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%; } diff --git a/client/src/pages/Achievements.tsx b/client/src/pages/Achievements.tsx new file mode 100644 index 0000000..244f2c6 --- /dev/null +++ b/client/src/pages/Achievements.tsx @@ -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 = { + 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('badges'); + const [badges, setBadges] = useState([]); + const [records, setRecords] = useState([]); + const [activities, setActivities] = useState([]); + 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
加载中…
; + + // Badges cluster heavily by year, which is the only grouping that reads. + const byYear = badges.reduce>((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 ( +
+
+
+

成就

+

奖励徽章、个人纪录与运动记录

+
+
+ + {error &&
{error}
} + +
+ + + + +
+ +
+ {([ + ['badges', `奖励 (${badges.length})`], + ['records', `个人纪录 (${records.length})`], + ['activities', `运动 (${activities.length})`], + ] as Array<[Tab, string]>).map(([id, text]) => ( + + ))} +
+ + {tab === 'badges' && ( + badges.length === 0 ? ( +

还没有同步到徽章。

+ ) : ( + years.map((year) => ( +
+

+ {year === '未知' ? '未知年份' : `${year} 年`} + {byYear[year].length} 个 +

+
+ {byYear[year].map((b) => ( +
+
{b.name || b.badge_key}
+
+ {date(b.earned_date)} + {b.earned_count && b.earned_count > 1 && ( + ×{b.earned_count} + )} +
+
+ ))} +
+
+ )) + ) + )} + + {tab === 'records' && ( + records.length === 0 ? ( +

还没有同步到个人纪录。

+ ) : ( +
+ + + + + + + + + + + {records.map((r) => ( + + + + + + + ))} + +
活动类型数值日期
{r.activity_name || '—'}{label(r.activity_type)} + {r.value != null ? r.value.toLocaleString(undefined, { + maximumFractionDigits: 2, + }) : '—'} + {date(r.achieved_at)}
+
+ ) + )} + + {tab === 'activities' && ( + activities.length === 0 ? ( +

所选区间内没有运动记录。

+ ) : ( +
+ + + + + + + + + + + + + {activities.map((a) => ( + + + + + + + + + ))} + +
时间类型时长距离消耗平均心率
{a.start_time?.slice(0, 16).replace('T', ' ')}{label(a.activity_type)} + {a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'} + + {a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'} + {a.calories != null ? `${Math.round(a.calories)}` : '—'}{a.heart_rate_average ?? '—'}
+
+ ) + )} +
+ ); +} + +export default Achievements; diff --git a/client/src/pages/Analysis.css b/client/src/pages/Analysis.css deleted file mode 100644 index 75fdda9..0000000 --- a/client/src/pages/Analysis.css +++ /dev/null @@ -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); - } -} diff --git a/client/src/pages/Analysis.tsx b/client/src/pages/Analysis.tsx deleted file mode 100644 index cf6b247..0000000 --- a/client/src/pages/Analysis.tsx +++ /dev/null @@ -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([]); - 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 ( -
-

数据分析

-

按指标查看趋势与统计

- -
-
- {METRICS.map((m) => ( - - ))} -
- -
- {RANGES.map((r) => ( - - ))} -
-
- - {error &&
{error}
} - {loading &&
加载中…
} - - {!loading && !error && ( - <> - {summary ? ( -
- - - - - = 0 ? '+' : ''}${fmt(summary.delta)} ${metric.unit}`} - tone={summary.delta >= 0 ? 'up' : 'down'} - /> - -
- ) : ( -

该指标在所选区间内暂无数据。

- )} - - - - )} -
- ); -} - -function Stat({ label, value, tone }: { label: string; value: string; tone?: 'up' | 'down' }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -export default Analysis; diff --git a/client/src/pages/Dashboard.css b/client/src/pages/Dashboard.css deleted file mode 100644 index 456f608..0000000 --- a/client/src/pages/Dashboard.css +++ /dev/null @@ -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; - } -} diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 3c21eef..8b432b7 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -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 { + 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 { - 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([]); - const [today, setToday] = useState(null); + const [days, setDays] = useState([]); 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( - start.toISOString().slice(0, 10), - endStr + const start = new Date(end.getTime() - (DAYS - 1) * 86400000); + setDays( + await apiClient.getHealthSummary( + start.toISOString().slice(0, 10), + 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
加载中…
; - 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 ( +
+

今日概览

+
{error}
+
+ ); + } - const show = (v: number | null | undefined, suffix = '') => - v == null ? '—' : `${v.toLocaleString()}${suffix}`; + if (days.length === 0) { + return ( +
+

今日概览

+
+

还没有任何健康数据。

+ 去同步 Garmin 数据 +
+
+ ); + } + + 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 (
-

健康仪表板

- - {error &&
{error}
} - - {!hasData && !error && ( -
-

还没有任何健康数据。

- - 去同步 Garmin 数据 - +
+
+

今日概览

+

{today.date} · 近 {days.length} 天数据

- )} + 查看全部趋势 → +
- {hasData && ( - <> -
-

今日

- {today ? ( -
- - - - -
- ) : ( -

今日暂无数据,最近一次记录见下方趋势。

+ {/* Activity ---------------------------------------------------------- */} +
+

活动

+
+ + + + + + + unit="小时" + /> +
+
-
-

近 30 天

-
- - - - -
-
+ {/* Heart & stress ---------------------------------------------------- */} +
+

心率与压力

+
+ + + + + + +
+
-
-

趋势

-
- - - - -
-
- - )} -
- ); -} + {/* Sleep & breathing -------------------------------------------------- */} +
+

睡眠与呼吸

+
+ + + + +
+

+ 查看睡眠分期详情 → +

+
-function StatCard({ icon, label, value }: { icon: string; label: string; value: string }) { - return ( -
-
{icon}
-
-
{label}
-
{value}
-
+ {/* Trends ------------------------------------------------------------- */} +
+

近 {days.length} 天趋势

+
+ + + + +
+
); } diff --git a/client/src/pages/Pages.css b/client/src/pages/Pages.css index 9ff34f8..f56e75d 100644 --- a/client/src/pages/Pages.css +++ b/client/src/pages/Pages.css @@ -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; + } } diff --git a/client/src/pages/Sleep.tsx b/client/src/pages/Sleep.tsx new file mode 100644 index 0000000..051e009 --- /dev/null +++ b/client/src/pages/Sleep.tsx @@ -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 { + 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([]); + 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( + (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 ( +
+
+
+

睡眠

+

分期、评分与夜间生理指标

+
+
+ {RANGES.map((r) => ( + + ))} +
+
+ + {error &&
{error}
} + {loading &&
加载中…
} + + {!loading && !error && nights.length === 0 && ( +
+

所选区间内没有睡眠记录。

+ 去同步数据 +
+ )} + + {!loading && !error && nights.length > 0 && ( + <> +
+

{nights.length} 晚平均

+
+ + + + + + +
+
+ +
+
+ +
+ +
+ + + + +
+
+ + )} +
+ ); +} + +export default Sleep; diff --git a/client/src/pages/Trends.tsx b/client/src/pages/Trends.tsx new file mode 100644 index 0000000..373f9f4 --- /dev/null +++ b/client/src/pages/Trends.tsx @@ -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; + 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) { + 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([]); + 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 = { 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 ( +
+
+
+

趋势

+

全部 {GROUPS.length} 组指标

+
+
+ {RANGES.map((r) => ( + + ))} +
+
+ +
+ {GROUPS.map((g) => ( + + ))} +
+ + {error &&
{error}
} + {loading &&
加载中…
} + + {!loading && !error && ( + <> + {summary ? ( +
+ + + + + = 0 ? '+' : ''}${fmt(summary.delta)}`} + unit={primary.unit} + /> + +
+ ) : ( +

该指标在所选区间内没有数据。

+ )} + +
+ +
+ + )} +
+ ); +} + +export default Trends; diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 9cd7c50..28e4d51 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -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( + const { data } = await this.client.get( '/health/activities', this.range(startDate, endDate) ); return data; } + async getBadges() { + const { data } = await this.client.get('/health/badges'); + return data; + } + + async getPersonalRecords() { + const { data } = await this.client.get( + '/health/personal-records' + ); + return data; + } + // --- analysis --- async getTrends(metricType: string, startDate?: string, endDate?: string) { const { data } = await this.client.get('/analysis/trends', { diff --git a/client/src/theme.css b/client/src/theme.css new file mode 100644 index 0000000..7d84f04 --- /dev/null +++ b/client/src/theme.css @@ -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); +}