From 7ab150537d800ce2909ced733ad678dbcd04f700 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 23:38:21 +0800 Subject: [PATCH] =?UTF-8?q?[=E9=98=B6=E6=AE=B510]=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=BB=A5=20Framework7=20=E9=87=8D=E5=BB=BA=EF=BC=8C=E9=87=87?= =?UTF-8?q?=E7=94=A8=20iOS=20=E5=8E=9F=E7=94=9F=E5=BD=A2=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按要求废弃手写外壳,改用 Framework7 React(theme=ios)。参照 PeakWatch 的信息架构与卡片语言。 保留(这些是资产,不该重来): - 数据层 services/api.ts、聚合 lib/aggregate.ts、参考区间 lib/ranges.ts - 图表组件 Chart / Ring / Sparkline / BandBar / MetricCard / MetricStrip - 经校验的配色令牌(色盲安全 + 对比度,浅深两档) 替换: - 路由与外壳交给 F7:五个 Tab 各自独立导航栈,推入详情页不影响其他 Tab - 页面转场、橡皮筋滚动、大标题折叠、半透明栏 —— 这些正是换框架的理由, 手写做不像 - 底部标签栏改用 F7 Toolbar,触控目标与安全区由框架处理 配色接入: - 新增 f7theme.css 把我们的令牌映射到 F7 的 CSS 变量,让它的导航栏/ 列表/面板与我们的图表同属一套设计,而不是两种视觉打架 - F7 的深色靠 .dark 类,我们的靠 data-theme,两者在 App 里同步切换 fix: 图标显示为原始名称(squ/hea/cale…) - iconIos/iconMd 引用的是 framework7-icons 字体,没装就只会渲染出名字 其他: - tsconfig moduleResolution 改为 bundler —— F7 用 exports 映射, node 解析方式找不到它的类型 - 登录页不套 Tab 外壳,未登录时不该出现导航 桌面与手机都要好看:内容在宽屏收进 1100px 居中列并加密卡片列数, 窄屏走底部标签栏;两档都已实机核对。 bundle 190KB -> 399KB,是换取原生手感的代价。 Co-Authored-By: Claude Haiku 4.5 --- backend/.env.example | 3 + client/.attic/App.old.tsx | 49 ++ client/.attic/Layout.css | 181 +++++++ client/.attic/Layout.tsx | 131 +++++ .../components => .attic}/ProtectedRoute.tsx | 0 client/package.json | 13 +- client/src/App.tsx | 137 +++-- client/src/components/Layout.css | 93 ---- client/src/components/Layout.tsx | 90 ---- client/src/components/Screen.css | 470 ++++++++++++++++++ client/src/components/Screen.tsx | 64 +++ client/src/components/charts/BandBar.css | 57 +++ client/src/components/charts/BandBar.tsx | 79 +++ client/src/components/charts/MetricCard.css | 124 +++++ client/src/components/charts/MetricCard.tsx | 86 ++++ client/src/components/charts/MetricStrip.css | 72 +++ client/src/components/charts/MetricStrip.tsx | 60 +++ client/src/f7theme.css | 105 ++++ client/src/index.css | 24 +- client/src/lib/ranges.ts | 154 ++++++ client/src/pages/AchievementsPage.tsx | 209 ++++++++ client/src/pages/DailyPage.tsx | 302 +++++++++++ client/src/pages/Dashboard.tsx | 188 +++---- client/src/pages/Health.tsx | 195 ++++++++ client/src/pages/HealthPage.tsx | 189 +++++++ client/src/pages/LoginPage.tsx | 239 +++++++++ client/src/pages/NotFoundPage.tsx | 13 + client/src/pages/Pages.css | 10 + client/src/pages/SettingsPage.tsx | 102 ++++ client/src/pages/SleepPage.tsx | 179 +++++++ client/src/pages/SyncPage.tsx | 366 ++++++++++++++ client/src/pages/{Dashboard.css => Today.css} | 0 client/src/pages/TodayPage.tsx | 163 ++++++ client/src/pages/TrendsPage.tsx | 343 +++++++++++++ client/src/routes.ts | 32 ++ client/tsconfig.json | 2 +- package-lock.json | 98 ++++ 37 files changed, 4266 insertions(+), 356 deletions(-) create mode 100644 client/.attic/App.old.tsx create mode 100644 client/.attic/Layout.css create mode 100644 client/.attic/Layout.tsx rename client/{src/components => .attic}/ProtectedRoute.tsx (100%) delete mode 100644 client/src/components/Layout.css delete mode 100644 client/src/components/Layout.tsx create mode 100644 client/src/components/Screen.css create mode 100644 client/src/components/Screen.tsx create mode 100644 client/src/components/charts/BandBar.css create mode 100644 client/src/components/charts/BandBar.tsx create mode 100644 client/src/components/charts/MetricCard.css create mode 100644 client/src/components/charts/MetricCard.tsx create mode 100644 client/src/components/charts/MetricStrip.css create mode 100644 client/src/components/charts/MetricStrip.tsx create mode 100644 client/src/f7theme.css create mode 100644 client/src/lib/ranges.ts create mode 100644 client/src/pages/AchievementsPage.tsx create mode 100644 client/src/pages/DailyPage.tsx create mode 100644 client/src/pages/Health.tsx create mode 100644 client/src/pages/HealthPage.tsx create mode 100644 client/src/pages/LoginPage.tsx create mode 100644 client/src/pages/NotFoundPage.tsx create mode 100644 client/src/pages/SettingsPage.tsx create mode 100644 client/src/pages/SleepPage.tsx create mode 100644 client/src/pages/SyncPage.tsx rename client/src/pages/{Dashboard.css => Today.css} (100%) create mode 100644 client/src/pages/TodayPage.tsx create mode 100644 client/src/pages/TrendsPage.tsx create mode 100644 client/src/routes.ts diff --git a/backend/.env.example b/backend/.env.example index 7af8644..2b67bef 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,6 +22,9 @@ JWT_SECRET=dev_secret_change_me JWT_EXPIRY_DAYS=7 # --- CORS (comma-separated allowed front-end origins) --- +# localhost stays in the production list on purpose: CORS is not an auth +# boundary — every data route requires a valid JWT — so allowing a developer's +# dev server costs nothing and saves toggling this on every session. CORS_ORIGIN=http://localhost:3000,http://localhost:5173 # --- AI models (text-only, large context) --- diff --git a/client/.attic/App.old.tsx b/client/.attic/App.old.tsx new file mode 100644 index 0000000..d497e53 --- /dev/null +++ b/client/.attic/App.old.tsx @@ -0,0 +1,49 @@ +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import Layout from './components/Layout'; +import ProtectedRoute from './components/ProtectedRoute'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import DataSync from './pages/DataSync'; +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'; +import Daily from './pages/Daily'; +import Health from './pages/Health'; +import { FEATURES } from './features'; + +function App() { + return ( + + + } /> + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {FEATURES.ai && ( + } /> + )} + } /> + + + + } + /> + + + ); +} + +export default App; diff --git a/client/.attic/Layout.css b/client/.attic/Layout.css new file mode 100644 index 0000000..c19820c --- /dev/null +++ b/client/.attic/Layout.css @@ -0,0 +1,181 @@ +.layout { + min-height: 100vh; + background: var(--surface-0); +} + +.header { + background: color-mix(in srgb, var(--surface-0) 82%, transparent); + backdrop-filter: saturate(160%) blur(14px); + -webkit-backdrop-filter: saturate(160%) blur(14px); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 20; +} + +.header-inner { + max-width: 1180px; + margin: 0 auto; + padding: 0 1.5rem; + display: flex; + align-items: center; + gap: 1.25rem; + height: 54px; +} + +.logo { + font-size: 0.92rem; + font-weight: 680; + color: var(--text-primary); + text-decoration: none; + white-space: nowrap; +} + +.nav { + display: flex; + gap: 0.15rem; + flex: 1; +} + +.nav-link { + padding: 0.38rem 0.7rem; + border-radius: 8px; + color: var(--text-secondary); + text-decoration: none; + font-size: 0.86rem; + white-space: nowrap; + transition: background 0.15s var(--ease), color 0.15s var(--ease); +} + +.nav-link:hover { + background: var(--surface-1); + color: var(--text-primary); +} + +.nav-link.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +/* Icon shortcuts only appear once the text nav collapses. */ +.header-secondary { + display: none; + gap: 0.1rem; +} + +.icon-link { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 9px; + color: var(--text-secondary); + text-decoration: none; + font-size: 1rem; + transition: background 0.15s var(--ease), color 0.15s var(--ease); +} + +.icon-link:hover, +.icon-link.active { + background: var(--accent-soft); + color: var(--accent); +} + +.theme-toggle { + background: none; + border: 1px solid var(--border); + color: var(--text-secondary); + border-radius: 8px; + padding: 0.3rem 0.6rem; + font-size: 0.74rem; + cursor: pointer; + font-family: inherit; + white-space: nowrap; + transition: border-color 0.15s var(--ease), color 0.15s var(--ease); +} + +.theme-toggle:hover { + border-color: var(--border-strong); + color: var(--text-primary); +} + +.content { + max-width: 1180px; + margin: 0 auto; + padding: 1.5rem 1.5rem 4rem; +} + +/* Bottom tab bar ----------------------------------------------------------- */ +.tabbar { + display: none; + position: fixed; + left: 50%; + transform: translateX(-50%); + /* Clears the iOS home indicator rather than sitting under it. */ + bottom: calc(0.6rem + env(safe-area-inset-bottom, 0px)); + z-index: 30; + gap: 0.15rem; + padding: 0.3rem; + border-radius: 999px; + background: color-mix(in srgb, var(--surface-2) 88%, transparent); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); + border: 1px solid var(--border); + box-shadow: 0 6px 28px rgba(0, 0, 0, 0.28); +} + +.tab { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.1rem; + /* Comfortably past the 44px touch-target minimum. */ + min-width: 60px; + padding: 0.42rem 0.5rem; + border-radius: 999px; + text-decoration: none; + color: var(--text-muted); + transition: color 0.18s var(--ease), background 0.18s var(--ease); +} + +.tab-icon { + font-size: 1.05rem; + line-height: 1; +} + +.tab-label { + font-size: 0.66rem; + font-weight: 550; +} + +.tab.active { + background: var(--accent-soft); + color: var(--accent); +} + +.tab:active { + transform: scale(0.94); +} + +@media (max-width: 860px) { + .nav { display: none; } + .header-secondary { display: flex; } + .tabbar { display: flex; } + .header-inner { padding: 0 0.9rem; gap: 0.5rem; } + .logo { font-size: 0.85rem; } + /* Room for the floating bar so the last card is never trapped under it. */ + .content { padding: 1.1rem 0.9rem calc(5.5rem + env(safe-area-inset-bottom, 0px)); } +} + +@media (prefers-reduced-motion: reduce) { + .nav-link, .icon-link, .tab, .theme-toggle { transition: none; } + .tab:active { transform: none; } +} diff --git a/client/.attic/Layout.tsx b/client/.attic/Layout.tsx new file mode 100644 index 0000000..6560980 --- /dev/null +++ b/client/.attic/Layout.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { FEATURES } from '../features'; +import './Layout.css'; + +interface LayoutProps { + children: React.ReactNode; +} + +interface NavItem { + path: string; + label: string; + icon: string; +} + +/** The five thumb-reachable destinations in the bottom bar. */ +const NAV: NavItem[] = [ + { path: '/', label: '今日', icon: '◱' }, + { path: '/health', label: '健康', icon: '♡' }, + { path: '/daily', label: '每日', icon: '◉' }, + { path: '/trends', label: '趋势', icon: '◈' }, + { path: '/achievements', label: '成就', icon: '▽' }, +]; + +/** Secondary destinations: the bottom bar only holds five. */ +const SECONDARY: NavItem[] = [ + { path: '/sleep', label: '睡眠', icon: '☾' }, + ...(FEATURES.ai ? [{ path: '/recommendations', label: '建议', icon: '✦' }] : []), + { path: '/sync', label: '同步', icon: '⟳' }, + { path: '/settings', label: '设置', icon: '⚙' }, +]; + +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]; +} + +const isActive = (pathname: string, path: string) => + path === '/' ? pathname === '/' : pathname.startsWith(path); + +function Layout({ children }: LayoutProps) { + const location = useLocation(); + const [theme, setTheme] = useTheme(); + + const cycle = () => + setTheme(theme === 'system' ? 'light' : theme === 'light' ? 'dark' : 'system'); + const themeLabel = { system: '自动', light: '浅色', dark: '深色' }[theme]; + + return ( +
+
+
+ Garmin Health Lab + + {/* Wide screens show every destination in one row; narrow screens use + the bottom bar instead, where the thumb already is. */} + + +
+ + +
+
+
+ +
{children}
+ + +
+ ); +} + +export default Layout; diff --git a/client/src/components/ProtectedRoute.tsx b/client/.attic/ProtectedRoute.tsx similarity index 100% rename from client/src/components/ProtectedRoute.tsx rename to client/.attic/ProtectedRoute.tsx diff --git a/client/package.json b/client/package.json index 0a4ef30..fc01eab 100644 --- a/client/package.json +++ b/client/package.json @@ -4,19 +4,22 @@ "private": true, "proxy": "http://localhost:5000", "dependencies": { + "axios": "^1.5.0", + "date-fns": "^2.30.0", + "framework7": "^9.1.2", + "framework7-icons": "^5.0.5", + "framework7-react": "^9.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.2", - "axios": "^1.5.0", "recharts": "^2.8.0", - "tailwindcss": "^3.3.2", - "date-fns": "^2.30.0" + "tailwindcss": "^3.3.2" }, "devDependencies": { "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", - "typescript": "^5.1.3", - "react-scripts": "5.0.1" + "react-scripts": "5.0.1", + "typescript": "^5.1.3" }, "scripts": { "start": "react-scripts start", diff --git a/client/src/App.tsx b/client/src/App.tsx index d0d9bb0..ffa7b10 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,46 +1,103 @@ -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; -import Layout from './components/Layout'; -import ProtectedRoute from './components/ProtectedRoute'; -import Login from './pages/Login'; -import Dashboard from './pages/Dashboard'; -import DataSync from './pages/DataSync'; -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'; -import Daily from './pages/Daily'; -import { FEATURES } from './features'; +import { useEffect, useState } from 'react'; +import { App as F7App, View, Views, Toolbar, Link } from 'framework7-react'; +import Framework7 from 'framework7/lite-bundle'; +import Framework7React from 'framework7-react'; + +import routes from './routes'; + +import 'framework7/css/bundle'; +// The icon font F7's iconIos/iconMd props reference; without it the props +// render as their raw names. +import 'framework7-icons'; +import './f7theme.css'; + +Framework7.use(Framework7React); + +/** The five thumb-reachable destinations. Secondary screens are pushed on top + * of whichever tab is active, the way an iOS app stacks them. */ +const TABS = [ + { id: 'today', path: '/', label: '今日', icon: 'square_grid_2x2' }, + { id: 'health', path: '/health/', label: '健康', icon: 'heart' }, + { id: 'daily', path: '/daily/', label: '每日', icon: 'calendar' }, + { id: 'trends', path: '/trends/', label: '趋势', icon: 'chart_bar_alt_fill' }, + { id: 'awards', path: '/achievements/', label: '成就', icon: 'rosette' }, +]; + +/** + * Dark mode is a deliberate, validated palette rather than an inversion. The + * choice is stamped on for our own tokens, and mirrored onto F7's + * `.dark` class so its chrome follows the same switch. + */ +function useTheme() { + const [theme, setTheme] = useState<'light' | 'dark' | 'system'>( + () => (localStorage.getItem('ghl_theme') as any) || 'system' + ); + + useEffect(() => { + const root = document.documentElement; + const media = window.matchMedia('(prefers-color-scheme: dark)'); + + const apply = () => { + if (theme === 'system') root.removeAttribute('data-theme'); + else root.setAttribute('data-theme', theme); + root.classList.toggle( + 'dark', + theme === 'dark' || (theme === 'system' && media.matches) + ); + }; + + apply(); + localStorage.setItem('ghl_theme', theme); + media.addEventListener('change', apply); + return () => media.removeEventListener('change', apply); + }, [theme]); + + return [theme, setTheme] as const; +} function App() { - return ( - - - } /> + useTheme(); - - - - } /> - } /> - } /> - } /> - } /> - } /> - {FEATURES.ai && ( - } /> - )} - } /> - - - - } - /> - - + return ( + + + + {TABS.map((tab) => ( + + ))} + + + {/* Each tab keeps its own navigation stack, so pushing a detail screen + inside 健康 does not disturb where 趋势 was left. */} + {TABS.map((tab) => ( + + ))} + + ); } diff --git a/client/src/components/Layout.css b/client/src/components/Layout.css deleted file mode 100644 index 94aeba9..0000000 --- a/client/src/components/Layout.css +++ /dev/null @@ -1,93 +0,0 @@ -.layout { - min-height: 100vh; - background: var(--surface-0); -} - -.header { - background: var(--surface-1); - border-bottom: 1px solid var(--border); - position: sticky; - top: 0; - z-index: 20; -} - -.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: 0.95rem; - font-weight: 680; - color: var(--text-primary); - text-decoration: none; - white-space: nowrap; -} - -.nav { - display: flex; - gap: 0.15rem; - flex: 1; - overflow-x: auto; - scrollbar-width: none; -} - -.nav::-webkit-scrollbar { display: none; } - -.nav-link { - padding: 0.4rem 0.7rem; - border-radius: 7px; - color: var(--text-secondary); - text-decoration: none; - font-size: 0.88rem; - white-space: nowrap; - transition: background 0.15s ease, color 0.15s ease; -} - -.nav-link:hover { - background: var(--surface-0); - color: var(--text-primary); -} - -.nav-link.active { - background: var(--accent-soft); - color: var(--accent); - font-weight: 600; -} - -.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; -} - -.theme-toggle:hover { - border-color: var(--border-strong); - color: var(--text-primary); -} - -.content { - max-width: 1180px; - margin: 0 auto; - padding: 1.75rem 1.5rem 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 deleted file mode 100644 index ce45d9a..0000000 --- a/client/src/components/Layout.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Link, useLocation } from 'react-router-dom'; -import { FEATURES } from '../features'; -import './Layout.css'; - -interface LayoutProps { - children: React.ReactNode; -} - -const NAV = [ - { path: '/', label: '今日' }, - { path: '/daily', label: '每日数据' }, - { path: '/trends', label: '趋势' }, - { path: '/sleep', label: '睡眠' }, - { path: '/achievements', label: '成就' }, - ...(FEATURES.ai ? [{ 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 cycle = () => - setTheme(theme === 'system' ? 'light' : theme === 'light' ? 'dark' : 'system'); - - const themeLabel = { system: '跟随系统', light: '浅色', dark: '深色' }[theme]; - - return ( -
-
-
- Garmin Health Lab - - - - -
-
- -
{children}
-
- ); -} - -export default Layout; diff --git a/client/src/components/Screen.css b/client/src/components/Screen.css new file mode 100644 index 0000000..1c9ba0b --- /dev/null +++ b/client/src/components/Screen.css @@ -0,0 +1,470 @@ +/* Sections ---------------------------------------------------------------- */ +.sec { + margin-bottom: 1.5rem; +} + +.sec-title { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin: 0 0 0.65rem; + font-size: 0.78rem; + font-weight: 650; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); +} + +.sec-count { + font-weight: 400; + letter-spacing: 0; + text-transform: none; +} + +.sec-link { + margin: 0.7rem 0 0; + font-size: 0.83rem; +} + +.sec-link a { color: var(--accent); text-decoration: none; } +.sec-link a:active { opacity: 0.6; } + +/* Desktop keeps the same cards but gets more of them per row, so a wide + window is filled with content instead of whitespace. */ +@media (min-width: 861px) { + .page-inner .mcard-grid { + grid-template-columns: repeat(auto-fit, minmax(232px, 1fr)); + } + .page-inner .chart-grid { + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + } +} + +/* Chrome bits shared by screens ------------------------------------------- */ +.screen-error { + background: color-mix(in srgb, var(--status-critical) 12%, var(--surface-1)); + border: 1px solid color-mix(in srgb, var(--status-critical) 35%, transparent); + color: var(--status-critical); + padding: 0.8rem 1rem; + border-radius: 12px; + margin-bottom: 1rem; + font-size: 0.86rem; +} + +.screen-ok { + background: color-mix(in srgb, var(--status-good) 12%, var(--surface-1)); + border: 1px solid color-mix(in srgb, var(--status-good) 35%, transparent); + color: var(--text-primary); + padding: 0.8rem 1rem; + border-radius: 12px; + margin-bottom: 1rem; + font-size: 0.86rem; +} + +.screen-empty { + text-align: center; + padding: 3rem 1.5rem; + background: var(--surface-1); + border: 1px dashed var(--border-strong); + border-radius: 14px; + color: var(--text-secondary); +} + +.screen-empty p { margin: 0 0 1.1rem; } + +.screen-note { + text-align: center; + padding: 1.75rem 1rem; + color: var(--text-muted); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 14px; + font-size: 0.88rem; +} + +.screen-disclaimer { + margin: 2rem 0 0; + padding-top: 1rem; + border-top: 1px solid var(--border); + font-size: 0.75rem; + color: var(--text-muted); + text-align: center; + line-height: 1.7; +} + +/* Segmented control, the iOS filter pattern. */ +.segmented-row { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.6rem; +} + +.segmented-label { + font-size: 0.74rem; + color: var(--text-muted); + flex-shrink: 0; + min-width: 2.2em; +} + +.segmented { + display: flex; + background: var(--surface-2); + border-radius: 9px; + padding: 2px; + gap: 2px; + overflow-x: auto; + scrollbar-width: none; + flex: 1; +} + +.segmented::-webkit-scrollbar { display: none; } + +.segmented button { + flex: 1; + min-width: max-content; + border: none; + background: none; + color: var(--text-secondary); + font-family: inherit; + font-size: 0.8rem; + padding: 0.34rem 0.7rem; + border-radius: 7px; + cursor: pointer; + white-space: nowrap; + transition: background 0.18s var(--ease), color 0.18s var(--ease); +} + +.segmented button.on { + background: var(--surface-1); + color: var(--text-primary); + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14); +} + +@media (prefers-reduced-motion: reduce) { + .segmented button { transition: none; } +} + +/* Day navigation (每日数据) ------------------------------------------------ */ +.day-nav { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.day-btn { + width: 40px; + height: 36px; + border-radius: 9px; + border: 1px solid var(--border); + background: var(--surface-1); + color: var(--text-secondary); + font-size: 1.15rem; + line-height: 1; + cursor: pointer; + font-family: inherit; + transition: background 0.15s var(--ease), color 0.15s var(--ease); +} + +.day-btn:active:not(:disabled) { transform: scale(0.94); } +.day-btn:disabled { opacity: 0.35; cursor: default; } + +.day-input { + flex: 1; + padding: 0.5rem 0.7rem; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-1); + color: var(--text-primary); + font-family: inherit; + font-size: 0.9rem; + text-align: center; +} + +.day-summary { + margin: 0 0 1.1rem; + font-size: 0.8rem; + color: var(--text-muted); +} + +.toggle-row { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 1rem; +} + +/* Metric list (每日数据) --------------------------------------------------- */ +.metric-list { + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; + background: var(--surface-1); +} + +.metric-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.62rem 0.9rem; + border-bottom: 1px solid var(--border); + border-right: 1px solid var(--border); +} + +.metric-row dt { + color: var(--text-secondary); + font-size: 0.84rem; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.metric-hint { color: var(--text-muted); font-size: 0.7rem; } +.metric-row dd { margin: 0; white-space: nowrap; text-align: right; } +.metric-value { color: var(--text-primary); font-weight: 620; font-size: 1rem; } +.metric-unit { color: var(--text-muted); font-size: 0.72rem; margin-left: 0.22rem; } +.metric-empty { color: var(--text-muted); font-size: 0.8rem; } + +/* Metric picker (趋势) ----------------------------------------------------- */ +.metric-picker { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 14px; + padding: 0.85rem 1rem; + margin-bottom: 1.1rem; +} + +.picker-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.6rem; +} + +.picker-actions { display: flex; gap: 0.85rem; } + +.link-button { + background: none; + border: none; + color: var(--accent); + font-size: 0.78rem; + cursor: pointer; + padding: 0; + font-family: inherit; +} + +.picker-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; } + +.chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.32rem 0.72rem; + border-radius: 999px; + font-size: 0.79rem; + cursor: pointer; + font-family: inherit; + border: 1px solid var(--border); + transition: all 0.15s var(--ease); +} + +.chip.on { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + color: var(--accent); + font-weight: 600; +} + +.chip.off { background: var(--surface-0); color: var(--text-muted); } +.chip:active { transform: scale(0.96); } +.chip-mark { font-size: 0.72em; opacity: 0.85; } + +.chart-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.85rem; +} + +.chart-stats { + display: flex; + gap: 0.85rem; + flex-wrap: wrap; + font-variant-numeric: tabular-nums; +} + +.chart-stats b { color: var(--text-primary); font-weight: 620; } + +/* Badges / tables ---------------------------------------------------------- */ +.badge-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.6rem; +} + +.badge { + background: var(--surface-1); + border: 1px solid var(--border); + border-left: 3px solid var(--series-4); + border-radius: 11px; + padding: 0.65rem 0.8rem; +} + +.badge-name { font-size: 0.84rem; font-weight: 600; color: var(--text-primary); line-height: 1.4; } +.badge-meta { margin-top: 0.28rem; font-size: 0.72rem; color: var(--text-muted); display: flex; gap: 0.45rem; } +.badge-count { color: var(--accent); font-weight: 600; } + +.table-wrap { + border: 1px solid var(--border); + border-radius: 14px; + overflow: auto; + background: var(--surface-1); + -webkit-overflow-scrolling: touch; +} + +.data-table { width: 100%; border-collapse: collapse; font-size: 0.83rem; } + +.data-table th, .data-table td { + padding: 0.58rem 0.8rem; + 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.72rem; + 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; } + +.metric-tabs { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-bottom: 1.1rem; +} + +.metric-tab { + padding: 0.34rem 0.8rem; + border: 1px solid var(--border); + background: var(--surface-1); + border-radius: 999px; + font-size: 0.81rem; + color: var(--text-secondary); + cursor: pointer; + font-family: inherit; +} + +.metric-tab.active { + background: var(--accent-solid); + border-color: var(--accent-solid); + color: #fff; +} + +/* Sync page ---------------------------------------------------------------- */ +.sync-container { display: grid; gap: 1rem; } + +.status-card, .sync-actions, .info-box { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1.1rem; +} + +.status-card h3, .info-box h4 { + margin: 0 0 0.85rem; + font-size: 0.92rem; + font-weight: 650; + color: var(--text-primary); +} + +.status-info { display: flex; flex-direction: column; gap: 0.45rem; } + +.status-item { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + padding: 0.52rem 0.7rem; + background: var(--surface-0); + border-radius: 9px; + font-size: 0.86rem; +} + +.status-item .label { color: var(--text-muted); font-size: 0.8rem; } +.status-item .value { color: var(--text-primary); font-weight: 550; text-align: right; } +.value.status-idle { color: var(--status-good); } +.value.status-syncing { color: var(--accent); } +.value.status-error { color: var(--status-critical); } + +.status-item.error { + background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-0)); + border: 1px solid color-mix(in srgb, var(--status-critical) 25%, transparent); +} + +.status-item.error .value { color: var(--status-critical); font-weight: 400; font-size: 0.78rem; word-break: break-word; } + +.sync-choices { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.mfa-card { border-color: var(--accent); background: var(--accent-soft); } +.code-input { font-size: 1.35rem; letter-spacing: 0.3em; text-align: center; } +.mfa-buttons { display: flex; gap: 0.6rem; } +.progress-block { display: flex; flex-direction: column; gap: 0.5rem; } +.progress-head { display: flex; justify-content: space-between; align-items: baseline; font-size: 0.88rem; color: var(--text-primary); } +.progress-count { font-variant-numeric: tabular-nums; color: var(--text-secondary); font-size: 0.8rem; } +.progress-bar { height: 6px; background: var(--surface-0); border-radius: 999px; overflow: hidden; } +.progress-fill { height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.4s var(--ease); } +.info-box ul { margin: 0; padding-left: 1.15rem; color: var(--text-secondary); line-height: 1.85; font-size: 0.84rem; } +.cmd { background: var(--surface-0); border: 1px solid var(--border); color: var(--text-primary); padding: 0.8rem; border-radius: 9px; font-size: 0.76rem; line-height: 1.75; overflow-x: auto; margin: 0; font-family: Menlo, Monaco, monospace; } +.field-hint { font-size: 0.77rem; color: var(--text-muted); line-height: 1.7; margin: 0; } + +.form-group { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 0.9rem; } +.form-group label { font-size: 0.83rem; font-weight: 600; color: var(--text-secondary); } +.form-group input { + padding: 0.65rem 0.8rem; + border: 1px solid var(--border); + border-radius: 10px; + font-size: 0.95rem; + font-family: inherit; + background: var(--surface-0); + color: var(--text-primary); +} +.form-group input:focus { outline: none; border-color: var(--accent); } + +/* Settings ----------------------------------------------------------------- */ +.settings-section { margin-bottom: 1.75rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--border); } +.settings-section:last-child { border-bottom: none; } +.settings-section h3 { font-size: 0.95rem; font-weight: 650; color: var(--text-primary); margin: 0 0 0.5rem; } +.settings-hint { color: var(--text-muted); font-size: 0.84rem; line-height: 1.75; margin-bottom: 0.9rem; } +.settings-list { margin: 0; padding-left: 1.2rem; color: var(--text-secondary); line-height: 1.9; font-size: 0.86rem; } +.btn-danger { + background: var(--surface-1); + color: var(--status-critical); + border: 1px solid color-mix(in srgb, var(--status-critical) 40%, transparent); + padding: 0.6rem 1.15rem; + border-radius: 10px; + font-size: 0.9rem; + cursor: pointer; + font-family: inherit; +} + +@media (prefers-reduced-motion: reduce) { + .day-btn:active:not(:disabled), .chip:active { transform: none; } + .progress-fill { transition: none; } +} diff --git a/client/src/components/Screen.tsx b/client/src/components/Screen.tsx new file mode 100644 index 0000000..95a0f27 --- /dev/null +++ b/client/src/components/Screen.tsx @@ -0,0 +1,64 @@ +import { ReactNode, useEffect, useState } from 'react'; +import { Page, Navbar, NavRight, Link, f7 } from 'framework7-react'; +import { apiClient } from '../services/api'; +import './Screen.css'; + +interface ScreenProps { + title: string; + subtitle?: string; + /** Shown as an iOS large title that collapses on scroll. */ + large?: boolean; + /** Back chevron instead of the app's utility links. */ + backLink?: boolean; + /** Skip the auth gate (the login screen itself). */ + open?: boolean; + right?: ReactNode; + children: ReactNode; +} + +/** + * Common page chrome. + * + * Every screen is an F7 `` so it inherits the platform behaviour we + * switched to Framework7 for — sliding transitions, rubber-band scrolling, + * collapsing large titles — while the content inside stays ours. + */ +function Screen({ + title, subtitle, large = true, backLink, open, right, children, +}: ScreenProps) { + const [authed, setAuthed] = useState(() => apiClient.isAuthenticated()); + + useEffect(() => { + if (open || authed) return; + // Router navigation rather than a redirect component: F7 owns history. + f7.views.current.router.navigate('/login/', { reloadAll: true }); + }, [authed, open]); + + useEffect(() => { + const onStorage = () => setAuthed(apiClient.isAuthenticated()); + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + if (!open && !authed) return ; + + return ( + + + + {right ?? ( + <> + + + + + )} + + + +
{children}
+
+ ); +} + +export default Screen; diff --git a/client/src/components/charts/BandBar.css b/client/src/components/charts/BandBar.css new file mode 100644 index 0000000..7f87b1a --- /dev/null +++ b/client/src/components/charts/BandBar.css @@ -0,0 +1,57 @@ +.bandbar { + position: relative; + display: flex; + width: 100%; + border-radius: 999px; + overflow: visible; +} + +.bandbar.vertical { + flex-direction: column-reverse; + height: 100%; + width: 6px; +} + +.bandbar-seg:first-child { border-radius: 999px 0 0 999px; } +.bandbar-seg:last-child { border-radius: 0 999px 999px 0; } +.bandbar.vertical .bandbar-seg:first-child { border-radius: 0 0 999px 999px; } +.bandbar.vertical .bandbar-seg:last-child { border-radius: 999px 999px 0 0; } + +.bandbar-seg { + height: 100%; + /* Bands are muted so the marker, not the track, is what the eye lands on. */ + opacity: 0.42; +} + +.bandbar.vertical .bandbar-seg { + width: 100%; + height: auto; +} + +.tone-good { background: var(--status-good); } +.tone-warning { background: var(--status-warning); } +.tone-serious { background: var(--status-serious); } +.tone-critical { background: var(--status-critical); } + +/* A 2px surface ring keeps the marker legible wherever it lands. */ +.bandbar-marker { + position: absolute; + top: 50%; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-primary); + border: 2px solid var(--surface-1); + transform: translate(-50%, -50%); + transition: left 0.9s var(--ease), bottom 0.9s var(--ease); +} + +.bandbar.vertical .bandbar-marker { + top: auto; + left: 50%; + transform: translate(-50%, 50%); +} + +@media (prefers-reduced-motion: reduce) { + .bandbar-marker { transition: none; } +} diff --git a/client/src/components/charts/BandBar.tsx b/client/src/components/charts/BandBar.tsx new file mode 100644 index 0000000..8dda463 --- /dev/null +++ b/client/src/components/charts/BandBar.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { Band, Range } from '../../lib/ranges'; +import { usePrefersReducedMotion } from '../../lib/motion'; +import './BandBar.css'; + +interface BandBarProps { + range: Range; + value: number | null; + /** Vertical layout for compact rows of metrics. */ + vertical?: boolean; + height?: number; +} + +/** + * The reference bands as a segmented track, with a marker at the current value. + * + * This is what turns a bare number into a judgement the reader can check: the + * marker's position shows *why* the value earned its label. The band colours + * come from the reserved status palette, and the label itself is always + * rendered as text beside the bar — colour never carries the verdict alone. + */ +function BandBar({ range, value, vertical = false, height = 6 }: BandBarProps) { + const reduced = usePrefersReducedMotion(); + const [settled, setSettled] = useState(reduced); + + useEffect(() => { + if (reduced) return; + const id = requestAnimationFrame(() => setSettled(true)); + return () => cancelAnimationFrame(id); + }, [reduced]); + + const min = range.min ?? 0; + const max = range.max ?? range.bands[range.bands.length - 2]?.max ?? 100; + const span = max - min || 1; + + // Segment widths are proportional to how much of the scale each band covers, + // so a wide "normal" band looks wide — equal-width segments would misstate + // how much room there is inside each verdict. + const segments = range.bands.map((band: Band, i) => { + const lower = i === 0 ? min : range.bands[i - 1].max; + const upper = Math.min(band.max === Infinity ? max : band.max, max); + return { band, size: (Math.max(0, upper - lower) / span) * 100 }; + }); + + const position = + value == null ? null : Math.min(100, Math.max(0, ((value - min) / span) * 100)); + + return ( +
+ {segments.map(({ band, size }, i) => ( + + ))} + + {position != null && ( + + )} +
+ ); +} + +export default BandBar; diff --git a/client/src/components/charts/MetricCard.css b/client/src/components/charts/MetricCard.css new file mode 100644 index 0000000..c7283f3 --- /dev/null +++ b/client/src/components/charts/MetricCard.css @@ -0,0 +1,124 @@ +.mcard { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 14px; + padding: 0.9rem 1rem 1rem; + display: flex; + flex-direction: column; + gap: 0.45rem; + text-align: left; + font-family: inherit; + width: 100%; + box-shadow: var(--shadow); + transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease), + border-color 0.2s var(--ease); +} + +.mcard.clickable { + cursor: pointer; +} + +.mcard:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lift); + border-color: var(--border-strong); +} + +.mcard-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + min-height: 20px; +} + +.mcard-label { + font-size: 0.82rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mcard-chevron { + color: var(--text-muted); + font-size: 1.1rem; + line-height: 1; +} + +/* Proportional figures: tabular-nums would make a large standalone value look + loose. Tabular is reserved for columns that must align. */ +.mcard-value { + font-size: 1.85rem; + font-weight: 660; + line-height: 1.05; + color: var(--text-primary); + letter-spacing: -0.015em; +} + +.mcard-unit { + font-size: 0.72rem; + font-weight: 400; + color: var(--text-muted); + margin-left: 0.2rem; +} + +.mcard-verdict { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.76rem; +} + +.mcard-tone { + font-weight: 650; + display: inline-flex; + align-items: baseline; + gap: 0.25rem; +} + +.mcard-tone.tone-good { background: none; color: var(--status-good); } +.mcard-tone.tone-warning { background: none; color: var(--status-warning); } +.mcard-tone.tone-serious { background: none; color: var(--status-serious); } +.mcard-tone.tone-critical { background: none; color: var(--status-critical); } + +.mcard-target { + color: var(--text-muted); + white-space: nowrap; +} + +.mcard-detail { + font-size: 0.76rem; + color: var(--text-muted); +} + +/* Two columns, the way the phone apps lay these out. */ +.mcard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 0.75rem; +} + +.mcard-grid > * { + animation: tile-in 0.42s var(--ease) both; +} + +.mcard-grid > *:nth-child(2) { animation-delay: 45ms; } +.mcard-grid > *:nth-child(3) { animation-delay: 90ms; } +.mcard-grid > *:nth-child(4) { animation-delay: 135ms; } +.mcard-grid > *:nth-child(5) { animation-delay: 180ms; } +.mcard-grid > *:nth-child(n + 6) { animation-delay: 220ms; } + +@media (max-width: 560px) { + .mcard-grid { grid-template-columns: repeat(2, 1fr); } + .mcard { padding: 0.75rem 0.8rem 0.85rem; border-radius: 12px; } + .mcard-value { font-size: 1.45rem; } + .mcard-target { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .mcard { transition: none; animation: none; } + .mcard:hover { transform: none; } + .mcard-grid > * { animation: none; } +} diff --git a/client/src/components/charts/MetricCard.tsx b/client/src/components/charts/MetricCard.tsx new file mode 100644 index 0000000..ce0a132 --- /dev/null +++ b/client/src/components/charts/MetricCard.tsx @@ -0,0 +1,86 @@ +import { ReactNode } from 'react'; +import { classify, formatTarget } from '../../lib/ranges'; +import { useCountUp } from '../../lib/motion'; +import BandBar from './BandBar'; +import Sparkline from './Sparkline'; +import './MetricCard.css'; + +/* Status is icon + word + colour, never colour alone — the light-surface + status steps sit below 3:1 by design. */ +const TONE_ICON: Record = { + good: '✓', + warning: '!', + serious: '↓', + critical: '!', +}; + +interface MetricCardProps { + /** Key into RANGES; when absent the card shows no verdict. */ + metric?: string; + label: string; + value: number | null | undefined; + unit?: string; + decimals?: number; + /** Recent history, drawn as an inline sparkline when supplied. */ + trend?: Array; + /** Extra line under the value. */ + detail?: ReactNode; + onClick?: () => void; +} + +function MetricCard({ + metric, label, value, unit, decimals = 0, trend, detail, onClick, +}: MetricCardProps) { + const numeric = typeof value === 'number' ? value : null; + const animated = useCountUp(numeric); + const verdict = metric ? classify(metric, numeric) : null; + + const display = + numeric == null + ? '—' + : (animated ?? numeric).toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + }); + + const Tag = onClick ? 'button' : 'div'; + + return ( + +
+ {label} + {trend && trend.some((v) => v != null) ? ( + + ) : ( + onClick && + )} +
+ +
+ {display} + {unit && numeric != null && {unit}} +
+ + {verdict && numeric != null ? ( + <> +
+ + + {verdict.band.label} + + 参考 {formatTarget(verdict.range)} +
+ + + ) : ( + detail &&
{detail}
+ )} +
+ ); +} + +export default MetricCard; diff --git a/client/src/components/charts/MetricStrip.css b/client/src/components/charts/MetricStrip.css new file mode 100644 index 0000000..717117f --- /dev/null +++ b/client/src/components/charts/MetricStrip.css @@ -0,0 +1,72 @@ +.strip-card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem 1.1rem 1.1rem; + box-shadow: var(--shadow); +} + +.strip-title { + margin: 0 0 0.9rem; + font-size: 0.85rem; + font-weight: 650; + color: var(--text-secondary); +} + +.strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); + gap: 0.6rem; +} + +.strip-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + text-align: center; +} + +.strip-icon { + font-size: 1.05rem; + line-height: 1; +} + +.strip-meter { + height: 46px; + display: flex; + justify-content: center; + background: var(--surface-0); + border-radius: 999px; + padding: 3px; +} + +.strip-meter-empty { + display: block; + width: 5px; + height: 100%; + background: var(--border); + border-radius: 999px; +} + +.strip-value { + font-size: 1.02rem; + font-weight: 650; + color: var(--text-primary); + line-height: 1.1; +} + +.strip-unit { + font-size: 0.68rem; + color: var(--text-muted); +} + +.strip-tone { + font-size: 0.66rem; + font-weight: 650; +} + +.strip-tone.tone-good { background: none; color: var(--status-good); } +.strip-tone.tone-warning { background: none; color: var(--status-warning); } +.strip-tone.tone-serious { background: none; color: var(--status-serious); } +.strip-tone.tone-critical { background: none; color: var(--status-critical); } diff --git a/client/src/components/charts/MetricStrip.tsx b/client/src/components/charts/MetricStrip.tsx new file mode 100644 index 0000000..b055a86 --- /dev/null +++ b/client/src/components/charts/MetricStrip.tsx @@ -0,0 +1,60 @@ +import { classify } from '../../lib/ranges'; +import BandBar from './BandBar'; +import './MetricStrip.css'; + +export interface StripItem { + metric?: string; + icon: string; + label: string; + value: number | null | undefined; + unit?: string; + decimals?: number; +} + +/** + * A row of small metrics, each with its position in its own reference band. + * + * Denser than a card per metric, and useful precisely because the bands make + * five unrelated units comparable at a glance: the reader is comparing "where + * in range", not the raw numbers. + */ +function MetricStrip({ items, title }: { items: StripItem[]; title?: string }) { + return ( +
+ {title &&

{title}

} +
+ {items.map((item) => { + const numeric = typeof item.value === 'number' ? item.value : null; + const verdict = item.metric ? classify(item.metric, numeric) : null; + return ( +
+ +
+ {verdict ? ( + + ) : ( + + )} +
+
+ {numeric == null + ? '—' + : numeric.toLocaleString(undefined, { + maximumFractionDigits: item.decimals ?? 0, + })} +
+
{item.unit ?? item.label}
+ {verdict && ( +
+ {verdict.band.label} +
+ )} +
+ ); + })} +
+
+ ); +} + +export default MetricStrip; diff --git a/client/src/f7theme.css b/client/src/f7theme.css new file mode 100644 index 0000000..1ed0473 --- /dev/null +++ b/client/src/f7theme.css @@ -0,0 +1,105 @@ +/* + * Framework7 theming. + * + * F7 ships its own colour system; rather than fight it, our validated tokens + * are mapped onto its CSS variables so its chrome (navbars, toolbars, lists, + * sheets) and our own charts stay one design. The series colours themselves + * are untouched — they were validated against these exact surfaces. + */ + +:root { + --f7-font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', + 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Roboto, sans-serif; + + --f7-theme-color: var(--accent); + --f7-theme-color-rgb: 42, 120, 214; + --f7-theme-color-shade: var(--accent-solid); + --f7-theme-color-tint: var(--accent); + + --f7-page-bg-color: var(--surface-0); + --f7-bars-bg-color: color-mix(in srgb, var(--surface-0) 84%, transparent); + --f7-bars-border-color: var(--border); + --f7-bars-text-color: var(--text-primary); + --f7-bars-link-color: var(--accent); + + --f7-text-color: var(--text-primary); + --f7-block-title-text-color: var(--text-muted); + --f7-block-strong-bg-color: var(--surface-1); + + --f7-list-bg-color: var(--surface-1); + --f7-list-border-color: var(--border); + --f7-list-item-border-color: var(--border); + --f7-list-item-title-text-color: var(--text-primary); + --f7-list-item-after-text-color: var(--text-secondary); + --f7-list-item-footer-text-color: var(--text-muted); + --f7-list-chevron-icon-color: var(--text-muted); + + --f7-sheet-bg-color: var(--surface-1); + --f7-sheet-border-color: var(--border); + --f7-popup-bg-color: var(--surface-0); + + --f7-toolbar-bg-color: color-mix(in srgb, var(--surface-2) 86%, transparent); + --f7-toolbar-border-color: var(--border); + --f7-tabbar-link-inactive-color: var(--text-muted); + --f7-tabbar-link-active-color: var(--accent); + + --f7-navbar-title-font-weight: 660; + --f7-navbar-height: 48px; + --f7-safe-area-bottom: env(safe-area-inset-bottom, 0px); +} + +/* F7 switches its own dark styles on a .dark class; ours keys off + data-theme / prefers-color-scheme, so the class is applied in tandem. */ +.dark { + --f7-theme-color-rgb: 57, 135, 229; +} + +/* Chrome ------------------------------------------------------------------- */ +.navbar, +.toolbar { + backdrop-filter: saturate(180%) blur(18px); + -webkit-backdrop-filter: saturate(180%) blur(18px); +} + +.navbar-bg::after, +.toolbar::before, +.toolbar::after { + background-color: var(--border); +} + +.page-content { + background: var(--surface-0); +} + +/* Our content sits in a centred column on wide screens so a desktop browser + does not stretch phone-shaped cards across 2000px. */ +.page-content > .page-inner { + max-width: 1100px; + margin: 0 auto; + padding: 1rem 1rem calc(2rem + var(--f7-safe-area-bottom)); + width: 100%; +} + +@media (min-width: 861px) { + .page-content > .page-inner { + padding: 1.5rem 1.75rem 3rem; + } +} + +/* Large title, the way iOS does it. */ +.title-large-text { + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +/* Sheets/dialogs pick up the app's radius rather than F7's default. */ +.sheet-modal, +.dialog { + border-radius: 18px 18px 0 0; +} + +.dialog { + border-radius: 16px; + background: var(--surface-1); +} diff --git a/client/src/index.css b/client/src/index.css index 4f44565..ec51d26 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,14 +1,20 @@ @import './theme.css'; -* { margin: 0; padding: 0; box-sizing: border-box; } +* { box-sizing: border-box; } -body { - 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: var(--surface-0); - color: var(--text-primary); +html, body, #root { + width: 100%; + height: 100%; + margin: 0; + padding: 0; } -html, body, #root { width: 100%; min-height: 100%; } +body { + background: var(--surface-0); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + /* Framework7 supplies the family; this only guards against a flash before + its stylesheet applies. */ + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; +} diff --git a/client/src/lib/ranges.ts b/client/src/lib/ranges.ts new file mode 100644 index 0000000..f3f533b --- /dev/null +++ b/client/src/lib/ranges.ts @@ -0,0 +1,154 @@ +import { Status } from '../components/charts/StatTile'; + +/** + * Reference bands for each metric. + * + * These are general-population orientation ranges, not diagnostic thresholds — + * they exist so a number reads as "where does this sit" rather than as a bare + * figure, and the UI labels them 参考区间 and keeps the medical disclaimer. + * Where Garmin publishes its own banding (stress, body battery) that banding + * is used, so the app and the watch never disagree. + */ +export interface Band { + /** Upper bound of this band, inclusive. Infinity for the last one. */ + max: number; + label: string; + tone: Status; +} + +export interface Range { + bands: Band[]; + /** The band considered the target, shown as "参考 x~y". */ + goodFrom: number; + goodTo: number; + /** Higher values are better; drives which end of the meter reads as good. */ + higherIsBetter?: boolean; + unit?: string; + /** Scale bounds for the meter; defaults to the outermost bands. */ + min?: number; + max?: number; +} + +export const RANGES: Record = { + steps: { + bands: [ + { max: 5000, label: '偏低', tone: 'serious' }, + { max: 8000, label: '一般', tone: 'warning' }, + { max: 12000, label: '达标', tone: 'good' }, + { max: Infinity, label: '优秀', tone: 'good' }, + ], + goodFrom: 8000, goodTo: 12000, higherIsBetter: true, min: 0, max: 16000, + }, + heartRate: { + // Resting heart rate; lower is generally better in healthy adults. + bands: [ + { max: 50, label: '很低', tone: 'good' }, + { max: 65, label: '正常', tone: 'good' }, + { max: 75, label: '偏高', tone: 'warning' }, + { max: Infinity, label: '较高', tone: 'serious' }, + ], + goodFrom: 50, goodTo: 65, unit: 'bpm', min: 35, max: 95, + }, + heartRateVariability: { + bands: [ + { max: 25, label: '偏低', tone: 'serious' }, + { max: 40, label: '一般', tone: 'warning' }, + { max: 70, label: '良好', tone: 'good' }, + { max: Infinity, label: '很好', tone: 'good' }, + ], + goodFrom: 40, goodTo: 70, higherIsBetter: true, unit: 'ms', min: 0, max: 100, + }, + sleepDuration: { + bands: [ + { max: 6, label: '不足', tone: 'critical' }, + { max: 7, label: '偏少', tone: 'warning' }, + { max: 9, label: '充足', tone: 'good' }, + { max: Infinity, label: '偏多', tone: 'warning' }, + ], + goodFrom: 7, goodTo: 9, unit: '小时', min: 3, max: 11, + }, + sleepQuality: { + bands: [ + { max: 50, label: '较差', tone: 'serious' }, + { max: 70, label: '一般', tone: 'warning' }, + { max: 85, label: '良好', tone: 'good' }, + { max: Infinity, label: '优秀', tone: 'good' }, + ], + goodFrom: 70, goodTo: 85, higherIsBetter: true, min: 0, max: 100, + }, + // Garmin's own stress banding, so the app and the watch agree. + stress: { + bands: [ + { max: 25, label: '休息', tone: 'good' }, + { max: 50, label: '偏低', tone: 'good' }, + { max: 75, label: '中等', tone: 'warning' }, + { max: Infinity, label: '偏高', tone: 'serious' }, + ], + goodFrom: 0, goodTo: 50, min: 0, max: 100, + }, + spo2Avg: { + bands: [ + { max: 90, label: '偏低', tone: 'critical' }, + { max: 94, label: '略低', tone: 'warning' }, + { max: Infinity, label: '正常', tone: 'good' }, + ], + goodFrom: 95, goodTo: 100, higherIsBetter: true, unit: '%', min: 85, max: 100, + }, + respirationAvg: { + bands: [ + { max: 12, label: '偏低', tone: 'warning' }, + { max: 20, label: '正常', tone: 'good' }, + { max: Infinity, label: '偏高', tone: 'warning' }, + ], + goodFrom: 12, goodTo: 20, unit: '次/分', min: 6, max: 26, + }, + bodyBatteryHigh: { + bands: [ + { max: 25, label: '很低', tone: 'critical' }, + { max: 50, label: '偏低', tone: 'warning' }, + { max: 75, label: '良好', tone: 'good' }, + { max: Infinity, label: '充足', tone: 'good' }, + ], + goodFrom: 50, goodTo: 100, higherIsBetter: true, min: 0, max: 100, + }, + trainingReadiness: { + bands: [ + { max: 25, label: '很低', tone: 'critical' }, + { max: 50, label: '偏低', tone: 'warning' }, + { max: 75, label: '就绪', tone: 'good' }, + { max: Infinity, label: '很好', tone: 'good' }, + ], + goodFrom: 50, goodTo: 100, higherIsBetter: true, min: 0, max: 100, + }, + intensityMinutes: { + // WHO recommends ~150 moderate minutes a week, i.e. ~21 a day. + bands: [ + { max: 10, label: '偏少', tone: 'serious' }, + { max: 21, label: '一般', tone: 'warning' }, + { max: Infinity, label: '达标', tone: 'good' }, + ], + goodFrom: 21, goodTo: 60, higherIsBetter: true, unit: '分钟', min: 0, max: 60, + }, + floorsAscended: { + bands: [ + { max: 5, label: '偏少', tone: 'warning' }, + { max: 10, label: '达标', tone: 'good' }, + { max: Infinity, label: '优秀', tone: 'good' }, + ], + goodFrom: 5, goodTo: 10, higherIsBetter: true, unit: '层', min: 0, max: 20, + }, +}; + +export function classify(metric: string, value: number | null) { + const range = RANGES[metric]; + if (!range || value == null) return null; + const band = range.bands.find((b) => value <= b.max) ?? range.bands[range.bands.length - 1]; + return { band, range }; +} + +/** Format the target band the way the watch shows it: "8k~12k". */ +export function formatTarget(range: Range): string { + const compact = (v: number) => + v >= 10000 ? `${Math.round(v / 1000)}k` : v >= 1000 ? `${v / 1000}k` : String(v); + return `${compact(range.goodFrom)}~${compact(range.goodTo)}`; +} diff --git a/client/src/pages/AchievementsPage.tsx b/client/src/pages/AchievementsPage.tsx new file mode 100644 index 0000000..2774c0f --- /dev/null +++ b/client/src/pages/AchievementsPage.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from 'react'; +import { + apiClient, Activity, Badge, errorMessage, PersonalRecord, +} from '../services/api'; +import StatTile from '../components/charts/StatTile'; +import Skeleton from '../components/Skeleton'; +import Screen from '../components/Screen'; + +type Tab = 'badges' | 'records' | 'activities'; + +const ACTIVITY_LABEL: Record = { + running: '跑步', + cycling: '骑行', + walking: '步行', + hiking: '徒步', + swimming: '游泳', + table_tennis: '乒乓球', + strength_training: '力量训练', + indoor_cycling: '室内骑行', + treadmill_running: '跑步机', + fitness_equipment: '健身器械', +}; + +const label = (key: string | null) => + key ? ACTIVITY_LABEL[key] ?? key.replace(/_/g, ' ') : '—'; + +const date = (value: string | null) => (value ? value.slice(0, 10) : '—'); + +function AchievementsPage() { + const [tab, setTab] = useState('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 AchievementsPage; diff --git a/client/src/pages/DailyPage.tsx b/client/src/pages/DailyPage.tsx new file mode 100644 index 0000000..0a4c4e7 --- /dev/null +++ b/client/src/pages/DailyPage.tsx @@ -0,0 +1,302 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { apiClient, Activity, errorMessage, HealthDay } from '../services/api'; +import Skeleton from '../components/Skeleton'; +import './Daily.css'; +import Screen from '../components/Screen'; + +/** Every stored metric, grouped the way the device groups them. */ +interface Field { + key: keyof HealthDay | 'sleepDeep' | 'sleepLight' | 'sleepRem' | 'sleepAwake'; + label: string; + unit?: string; + /** Convert the raw stored value for display. */ + transform?: (v: number) => number; + decimals?: number; + hint?: string; +} + +const SECONDS_TO_HOURS = (v: number) => v / 3600; +const SECONDS_TO_MINUTES = (v: number) => v / 60; + +const GROUPS: Array<{ title: string; fields: Field[] }> = [ + { + title: '活动', + fields: [ + { key: 'steps', label: '步数', unit: '步' }, + { key: 'stepGoal', label: '步数目标', unit: '步' }, + { key: 'distanceMeters', label: '距离', unit: 'km', transform: (v) => v / 1000, decimals: 2 }, + { key: 'floorsAscended', label: '爬楼上行', unit: '层' }, + { key: 'floorsDescended', label: '爬楼下行', unit: '层' }, + { key: 'intensityMinutes', label: '强度分钟', unit: '分钟', hint: '中等及以上强度' }, + { key: 'activeSeconds', label: '活动时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 }, + { key: 'sedentarySeconds', label: '久坐时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 }, + ], + }, + { + title: '能量', + fields: [ + { key: 'caloriesBurned', label: '总消耗', unit: 'kcal' }, + { key: 'activeCalories', label: '活动消耗', unit: 'kcal' }, + { key: 'bmrCalories', label: '基础代谢', unit: 'kcal' }, + ], + }, + { + title: '心率', + fields: [ + { key: 'heartRate', label: '静息心率', unit: 'bpm' }, + { key: 'heartRateMin', label: '最低心率', unit: 'bpm' }, + { key: 'heartRateMax', label: '最高心率', unit: 'bpm' }, + { key: 'heartRateVariability', label: '心率变异性', unit: 'ms', decimals: 1, hint: 'HRV,反映恢复情况' }, + ], + }, + { + title: '压力与身体电量', + fields: [ + { key: 'stress', label: '平均压力' }, + { key: 'stressMax', label: '最高压力' }, + { key: 'bodyBatteryHigh', label: '身体电量最高' }, + { key: 'bodyBatteryLow', label: '身体电量最低' }, + { key: 'bodyBatteryCharged', label: '当日充能' }, + { key: 'bodyBatteryDrained', label: '当日消耗' }, + ], + }, + { + title: '睡眠', + fields: [ + { key: 'sleepDuration', label: '总时长', unit: '小时', decimals: 1 }, + { key: 'sleepQuality', label: '睡眠评分', unit: '/100' }, + { key: 'sleepDeep', label: '深睡', unit: '分钟', transform: SECONDS_TO_MINUTES }, + { key: 'sleepLight', label: '浅睡', unit: '分钟', transform: SECONDS_TO_MINUTES }, + { key: 'sleepRem', label: 'REM', unit: '分钟', transform: SECONDS_TO_MINUTES }, + { key: 'sleepAwake', label: '夜间清醒', unit: '分钟', transform: SECONDS_TO_MINUTES }, + { key: 'sleepSpo2Avg', label: '睡眠血氧', unit: '%', decimals: 1 }, + { key: 'sleepRespirationAvg', label: '睡眠呼吸', unit: '次/分', decimals: 1 }, + { key: 'sleepStressAvg', label: '睡眠压力', decimals: 1 }, + ], + }, + { + title: '血氧与呼吸', + fields: [ + { key: 'spo2Avg', label: '平均血氧', unit: '%', decimals: 1 }, + { key: 'spo2Min', label: '最低血氧', unit: '%' }, + { key: 'respirationAvg', label: '平均呼吸', unit: '次/分', decimals: 1 }, + { key: 'respirationMin', label: '最低呼吸', unit: '次/分', decimals: 1 }, + { key: 'respirationMax', label: '最高呼吸', unit: '次/分', decimals: 1 }, + ], + }, + { + title: '训练', + fields: [ + { key: 'trainingReadiness', label: '训练准备度', unit: '/100' }, + { key: 'vo2max', label: 'VO2max', decimals: 1 }, + { key: 'enduranceScore', label: '耐力分' }, + ], + }, +]; + +const ACTIVITY_LABEL: Record = { + running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步', + swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练', + indoor_cycling: '室内骑行', treadmill_running: '跑步机', +}; + +function valueOf(day: HealthDay, key: Field['key']): number | null { + if (key === 'sleepDeep') return day.sleep?.deepSeconds ?? null; + if (key === 'sleepLight') return day.sleep?.lightSeconds ?? null; + if (key === 'sleepRem') return day.sleep?.remSeconds ?? null; + if (key === 'sleepAwake') return day.sleep?.awakeSeconds ?? null; + const v = (day as any)[key]; + return typeof v === 'number' ? v : null; +} + +const iso = (d: Date) => d.toISOString().slice(0, 10); + +function DailyPage() { + const [date, setDate] = useState(() => iso(new Date())); + const [day, setDay] = useState(null); + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [onlyRecorded, setOnlyRecorded] = useState(true); + + const load = useCallback(async (target: string) => { + setLoading(true); + setError(''); + try { + const [summary, acts] = await Promise.all([ + apiClient.getHealthSummary(target, target), + apiClient.getActivities(target, target), + ]); + setDay(summary[0] ?? null); + setActivities(acts); + } catch (err: any) { + setError(errorMessage(err, '加载失败')); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(date); }, [date, load]); + + const shift = (delta: number) => { + const d = new Date(date); + d.setDate(d.getDate() + delta); + if (d > new Date()) return; + setDate(iso(d)); + }; + + const recorded = useMemo(() => { + if (!day) return 0; + return GROUPS.reduce( + (n, g) => n + g.fields.filter((f) => valueOf(day, f.key) != null).length, 0 + ); + }, [day]); + + const totalFields = GROUPS.reduce((n, g) => n + g.fields.length, 0); + const isToday = date === iso(new Date()); + + const fmt = (f: Field, raw: number) => { + const v = f.transform ? f.transform(raw) : raw; + const decimals = f.decimals ?? 0; + return v.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + }); + }; + + return ( + + +
+ + e.target.value && setDate(e.target.value)} + /> + +
+ +

+ {day ? `已记录 ${recorded} / ${totalFields} 项指标` : '该日无数据'} +

+ + {error &&
{error}
} + {loading && } + + {!loading && !error && !day && ( +
+

{date} 没有数据。可能当天未佩戴设备,或尚未同步到这一天。

+
+ )} + + {!loading && !error && day && ( + <> + + + {GROUPS.map((g) => { + const fields = onlyRecorded + ? g.fields.filter((f) => valueOf(day, f.key) != null) + : g.fields; + if (fields.length === 0) return null; + return ( +
+

+ {g.title} + {fields.length} 项 +

+
+ {fields.map((f) => { + const raw = valueOf(day, f.key); + return ( +
+
+ {f.label} + {f.hint && {f.hint}} +
+
+ {raw == null ? ( + 未记录 + ) : ( + <> + {fmt(f, raw)} + {f.unit && {f.unit}} + + )} +
+
+ ); + })} +
+
+ ); + })} + +
+

+ 运动记录 + {activities.length} 条 +

+ {activities.length === 0 ? ( +

当天没有运动记录。

+ ) : ( +
+ + + + + + + + + + + + + + {activities.map((a) => ( + + + + + + + + + + ))} + +
开始类型时长距离消耗平均心率最高心率
{a.start_time?.slice(11, 16)} + {ACTIVITY_LABEL[a.activity_type] ?? + a.activity_type?.replace(/_/g, ' ')} + + {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 ?? '—'}{a.heart_rate_max ?? '—'}
+
+ )} +
+ + )} +
+ ); +} + +export default DailyPage; diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 95e9593..6176d75 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -2,7 +2,8 @@ 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, { Status } from '../components/charts/StatTile'; +import MetricCard from '../components/charts/MetricCard'; +import MetricStrip from '../components/charts/MetricStrip'; import Ring from '../components/charts/Ring'; import Skeleton from '../components/Skeleton'; import { useCountUp } from '../lib/motion'; @@ -20,22 +21,6 @@ function avg(values: Array): number | null { 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', '正常']; -} - /** The one hero figure on this view: today's steps against the day's goal. */ function StepHero({ today, history }: { today: HealthDay; history: HealthDay[] }) { const goal = today.stepGoal ?? null; @@ -149,15 +134,6 @@ function Dashboard() { 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 (
@@ -174,141 +150,117 @@ function Dashboard() { {/* Activity ---------------------------------------------------------- */}

活动

-
- + d.steps)} - detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined} - progress={ - today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null - } /> - d.intensityMinutes)} + /> + d.floorsAscended)} + /> + d.distanceMeters)} value={today.distanceMeters != null ? today.distanceMeters / 1000 : null} unit="km" decimals={2} - /> - - d.intensityMinutes)} - value={today.intensityMinutes} - unit="分钟" - detail="中等以上强度" - /> - d.caloriesBurned)} - value={round(today.caloriesBurned)} - unit="kcal" + trend={days.map((d) => d.distanceMeters)} detail={ - today.activeCalories != null - ? `其中活动 ${Math.round(today.activeCalories)}` + today.caloriesBurned != null + ? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal` : undefined } /> - d.sedentarySeconds)} - value={today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null} - unit="小时" - decimals={1} - />
{/* Heart & stress ---------------------------------------------------- */}

心率与压力

-
- + d.heartRate)} value={today.heartRate} unit="bpm" - status={rhrTone} - statusLabel={rhrWord} - detail={avgRhr != null ? `30 日均 ${Math.round(avgRhr)}` : undefined} + trend={days.map((d) => d.heartRate)} /> - - d.heartRateVariability)} - value={round(today.heartRateVariability)} - unit="ms" - detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined} /> - d.stress)} value={today.stress} - detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined} + trend={days.map((d) => d.stress)} /> - d.trainingReadiness)} /> -
{/* Sleep & breathing -------------------------------------------------- */}

睡眠与呼吸

-
- + d.sleepDuration)} value={today.sleepDuration} unit="小时" decimals={1} - status={sleepTone} - statusLabel={sleepWord} - detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined} + trend={days.map((d) => d.sleepDuration)} /> - - d.spo2Avg)} - value={round(today.spo2Avg)} - unit="%" - detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined} - /> - d.respirationAvg)} - value={round(today.respirationAvg)} - unit="次/分" - detail={ - today.respirationMin != null && today.respirationMax != null - ? `${today.respirationMin}–${today.respirationMax}` - : undefined - } + d.sleepQuality)} />
+ +
+ + +

查看睡眠分期详情 →

diff --git a/client/src/pages/Health.tsx b/client/src/pages/Health.tsx new file mode 100644 index 0000000..30e520d --- /dev/null +++ b/client/src/pages/Health.tsx @@ -0,0 +1,195 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { apiClient, errorMessage, HealthDay } from '../services/api'; +import MetricCard from '../components/charts/MetricCard'; +import Skeleton from '../components/Skeleton'; +import './Pages.css'; + +const WINDOW_DAYS = 30; + +interface Item { + metric?: string; + label: string; + pick: (d: HealthDay) => number | null; + unit?: string; + decimals?: number; + detail?: (d: HealthDay) => string | undefined; +} + +const SECTIONS: Array<{ title: string; items: Item[] }> = [ + { + title: '身体指标', + items: [ + { metric: 'heartRate', label: '静息心率', pick: (d) => d.heartRate, unit: 'bpm' }, + { + metric: 'heartRateVariability', label: '心率变异性', + pick: (d) => d.heartRateVariability, unit: 'ms', decimals: 1, + }, + { metric: 'respirationAvg', label: '呼吸频率', pick: (d) => d.respirationAvg, unit: '次/分', decimals: 1 }, + { metric: 'spo2Avg', label: '血氧', pick: (d) => d.spo2Avg, unit: '%' }, + ], + }, + { + title: '恢复', + items: [ + { metric: 'bodyBatteryHigh', label: '身体电量峰值', pick: (d) => d.bodyBatteryHigh }, + { metric: 'stress', label: '平均压力', pick: (d) => d.stress }, + { metric: 'trainingReadiness', label: '训练准备度', pick: (d) => d.trainingReadiness, unit: '/100' }, + { label: '耐力分', pick: (d) => d.enduranceScore }, + ], + }, + { + title: '睡眠', + items: [ + { metric: 'sleepDuration', label: '睡眠时长', pick: (d) => d.sleepDuration, unit: '小时', decimals: 1 }, + { metric: 'sleepQuality', label: '睡眠评分', pick: (d) => d.sleepQuality, unit: '/100' }, + { + label: '深睡占比', unit: '%', + pick: (d) => + d.sleep?.deepSeconds != null && d.sleepDuration + ? (d.sleep.deepSeconds / 3600 / d.sleepDuration) * 100 + : null, + decimals: 0, + }, + { + label: 'REM 占比', unit: '%', + pick: (d) => + d.sleep?.remSeconds != null && d.sleepDuration + ? (d.sleep.remSeconds / 3600 / d.sleepDuration) * 100 + : null, + decimals: 0, + }, + ], + }, + { + title: '活动', + items: [ + { metric: 'steps', label: '步数', pick: (d) => d.steps, unit: '步' }, + { metric: 'intensityMinutes', label: '强度分钟', pick: (d) => d.intensityMinutes, unit: '分钟' }, + { metric: 'floorsAscended', label: '爬楼', pick: (d) => d.floorsAscended, unit: '层' }, + { + label: '距离', unit: 'km', decimals: 2, + pick: (d) => (d.distanceMeters != null ? d.distanceMeters / 1000 : null), + }, + ], + }, + { + title: '能量', + items: [ + { label: '总消耗', pick: (d) => d.caloriesBurned, unit: 'kcal' }, + { label: '活动消耗', pick: (d) => d.activeCalories, unit: 'kcal' }, + { label: '基础代谢', pick: (d) => d.bmrCalories, unit: 'kcal' }, + { + label: '久坐', unit: '小时', decimals: 1, + pick: (d) => (d.sedentarySeconds != null ? d.sedentarySeconds / 3600 : null), + }, + ], + }, +]; + +function Health() { + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const load = async () => { + try { + const end = new Date(); + const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000); + setDays( + await apiClient.getHealthSummary( + start.toISOString().slice(0, 10), + end.toISOString().slice(0, 10) + ) + ); + } catch (err: any) { + setError(errorMessage(err, '加载失败')); + } finally { + setLoading(false); + } + }; + load(); + }, []); + + if (loading) { + return ( +
+

健康

+ +
+ ); + } + + if (error) { + return ( +
+

健康

+
{error}
+
+ ); + } + + if (days.length === 0) { + return ( +
+

健康

+
+

还没有任何健康数据。

+ 去同步 Garmin 数据 +
+
+ ); + } + + // The most recent day that actually recorded a given metric — showing "—" + // because today's sleep has not synced yet would hide data that exists. + const latest = (pick: (d: HealthDay) => number | null) => { + for (let i = days.length - 1; i >= 0; i--) { + const v = pick(days[i]); + if (v != null) return { value: v, date: days[i].date }; + } + return { value: null, date: null }; + }; + + return ( +
+
+
+

健康

+

每项指标的最新值与参考区间

+
+
+ + {SECTIONS.map((section) => ( +
+

{section.title}

+
+ {section.items.map((item) => { + const { value, date } = latest(item.pick); + const stale = date != null && date !== days[days.length - 1].date; + return ( + + ); + })} +
+
+ ))} + +

+ 参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。 +

+
+ ); +} + +export default Health; diff --git a/client/src/pages/HealthPage.tsx b/client/src/pages/HealthPage.tsx new file mode 100644 index 0000000..886f35a --- /dev/null +++ b/client/src/pages/HealthPage.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'framework7-react'; +import { apiClient, errorMessage, HealthDay } from '../services/api'; +import MetricCard from '../components/charts/MetricCard'; +import Skeleton from '../components/Skeleton'; +import Screen from '../components/Screen'; + +const WINDOW_DAYS = 30; + +interface Item { + metric?: string; + label: string; + pick: (d: HealthDay) => number | null; + unit?: string; + decimals?: number; + detail?: (d: HealthDay) => string | undefined; +} + +const SECTIONS: Array<{ title: string; items: Item[] }> = [ + { + title: '身体指标', + items: [ + { metric: 'heartRate', label: '静息心率', pick: (d) => d.heartRate, unit: 'bpm' }, + { + metric: 'heartRateVariability', label: '心率变异性', + pick: (d) => d.heartRateVariability, unit: 'ms', decimals: 1, + }, + { metric: 'respirationAvg', label: '呼吸频率', pick: (d) => d.respirationAvg, unit: '次/分', decimals: 1 }, + { metric: 'spo2Avg', label: '血氧', pick: (d) => d.spo2Avg, unit: '%' }, + ], + }, + { + title: '恢复', + items: [ + { metric: 'bodyBatteryHigh', label: '身体电量峰值', pick: (d) => d.bodyBatteryHigh }, + { metric: 'stress', label: '平均压力', pick: (d) => d.stress }, + { metric: 'trainingReadiness', label: '训练准备度', pick: (d) => d.trainingReadiness, unit: '/100' }, + { label: '耐力分', pick: (d) => d.enduranceScore }, + ], + }, + { + title: '睡眠', + items: [ + { metric: 'sleepDuration', label: '睡眠时长', pick: (d) => d.sleepDuration, unit: '小时', decimals: 1 }, + { metric: 'sleepQuality', label: '睡眠评分', pick: (d) => d.sleepQuality, unit: '/100' }, + { + label: '深睡占比', unit: '%', + pick: (d) => + d.sleep?.deepSeconds != null && d.sleepDuration + ? (d.sleep.deepSeconds / 3600 / d.sleepDuration) * 100 + : null, + decimals: 0, + }, + { + label: 'REM 占比', unit: '%', + pick: (d) => + d.sleep?.remSeconds != null && d.sleepDuration + ? (d.sleep.remSeconds / 3600 / d.sleepDuration) * 100 + : null, + decimals: 0, + }, + ], + }, + { + title: '活动', + items: [ + { metric: 'steps', label: '步数', pick: (d) => d.steps, unit: '步' }, + { metric: 'intensityMinutes', label: '强度分钟', pick: (d) => d.intensityMinutes, unit: '分钟' }, + { metric: 'floorsAscended', label: '爬楼', pick: (d) => d.floorsAscended, unit: '层' }, + { + label: '距离', unit: 'km', decimals: 2, + pick: (d) => (d.distanceMeters != null ? d.distanceMeters / 1000 : null), + }, + ], + }, + { + title: '能量', + items: [ + { label: '总消耗', pick: (d) => d.caloriesBurned, unit: 'kcal' }, + { label: '活动消耗', pick: (d) => d.activeCalories, unit: 'kcal' }, + { label: '基础代谢', pick: (d) => d.bmrCalories, unit: 'kcal' }, + { + label: '久坐', unit: '小时', decimals: 1, + pick: (d) => (d.sedentarySeconds != null ? d.sedentarySeconds / 3600 : null), + }, + ], + }, +]; + +function HealthPage() { + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const load = async () => { + try { + const end = new Date(); + const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000); + setDays( + await apiClient.getHealthSummary( + start.toISOString().slice(0, 10), + end.toISOString().slice(0, 10) + ) + ); + } catch (err: any) { + setError(errorMessage(err, '加载失败')); + } finally { + setLoading(false); + } + }; + load(); + }, []); + + if (loading) { + return ( + +

健康

+ +
+ ); + } + + if (error) { + return ( + +

健康

+
{error}
+
+ ); + } + + if (days.length === 0) { + return ( + +

健康

+
+

还没有任何健康数据。

+ 去同步 Garmin 数据 +
+
+ ); + } + + // The most recent day that actually recorded a given metric — showing "—" + // because today's sleep has not synced yet would hide data that exists. + const latest = (pick: (d: HealthDay) => number | null) => { + for (let i = days.length - 1; i >= 0; i--) { + const v = pick(days[i]); + if (v != null) return { value: v, date: days[i].date }; + } + return { value: null, date: null }; + }; + + return ( + + + {SECTIONS.map((section) => ( +
+

{section.title}

+
+ {section.items.map((item) => { + const { value, date } = latest(item.pick); + const stale = date != null && date !== days[days.length - 1].date; + return ( + + ); + })} +
+
+ ))} + +

+ 参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。 +

+
+ ); +} + +export default HealthPage; diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx new file mode 100644 index 0000000..c566b06 --- /dev/null +++ b/client/src/pages/LoginPage.tsx @@ -0,0 +1,239 @@ +import React, { useEffect, useState } from 'react'; +import { Page, f7 } from 'framework7-react'; +import { apiClient, errorMessage } from '../services/api'; +import './Login.css'; + +type TabType = 'login' | 'register'; + +function Login() { + const [activeTab, setActiveTab] = useState('login'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + // Sign-up closes once an account exists, so the tab is hidden rather than + // offering something the server will refuse. + const [canRegister, setCanRegister] = useState(false); + + useEffect(() => { + apiClient + .getRegistrationStatus() + .then(setCanRegister) + .catch(() => setCanRegister(false)); + }, []); + + // Login form + const [loginEmail, setLoginEmail] = useState(''); + const [loginPassword, setLoginPassword] = useState(''); + + // Register form + const [regEmail, setRegEmail] = useState(''); + const [regGarminEmail, setRegGarminEmail] = useState(''); + const [regPassword, setRegPassword] = useState(''); + const [regConfirmPassword, setRegConfirmPassword] = useState(''); + + const validateEmail = (email: string): boolean => { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); + }; + + const validatePassword = (password: string): boolean => { + return password.length >= 6; + }; + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!validateEmail(loginEmail)) { + setError('Please enter a valid email'); + return; + } + + if (!validatePassword(loginPassword)) { + setError('Password must be at least 6 characters'); + return; + } + + setLoading(true); + + try { + const { token } = await apiClient.login(loginEmail, loginPassword); + apiClient.setSession(token); + f7.views.main.router.navigate('/', { reloadAll: true }); + } catch (err: any) { + setError(errorMessage(err, '登录失败')); + } finally { + setLoading(false); + } + }; + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!validateEmail(regEmail)) { + setError('Please enter a valid email'); + return; + } + + if (!validateEmail(regGarminEmail)) { + setError('Please enter a valid Garmin email'); + return; + } + + if (!validatePassword(regPassword)) { + setError('Password must be at least 6 characters'); + return; + } + + if (regPassword !== regConfirmPassword) { + setError('Passwords do not match'); + return; + } + + setLoading(true); + + try { + const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword); + apiClient.setSession(token); + f7.views.main.router.navigate('/', { reloadAll: true }); + } catch (err: any) { + setError(errorMessage(err, '注册失败')); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+
+

🏃 Garmin Health Lab

+

健康数据分析平台

+
+ +
+ + {canRegister && ( + + )} +
+ + {error &&
{error}
} + + {activeTab === 'login' && ( +
+
+ + setLoginEmail(e.target.value)} + placeholder="example@example.com" + required + disabled={loading} + /> +
+ +
+ + setLoginPassword(e.target.value)} + placeholder="••••••••" + required + disabled={loading} + /> +
+ + +
+ )} + + {activeTab === 'register' && canRegister && ( +
+
+ + setRegEmail(e.target.value)} + placeholder="example@example.com" + required + disabled={loading} + /> +
+ +
+ + setRegGarminEmail(e.target.value)} + placeholder="garmin@example.com" + required + disabled={loading} + /> +
+ +
+ + setRegPassword(e.target.value)} + placeholder="••••••••" + required + disabled={loading} + /> +
+ +
+ + setRegConfirmPassword(e.target.value)} + placeholder="••••••••" + required + disabled={loading} + /> +
+ + +
+ )} +
+
+
+ ); +} + +export default Login; diff --git a/client/src/pages/NotFoundPage.tsx b/client/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..e9ed7a1 --- /dev/null +++ b/client/src/pages/NotFoundPage.tsx @@ -0,0 +1,13 @@ +import Screen from '../components/Screen'; + +function NotFoundPage() { + return ( + +
+

这个地址不存在。

+
+
+ ); +} + +export default NotFoundPage; diff --git a/client/src/pages/Pages.css b/client/src/pages/Pages.css index 4b1b18c..87e1164 100644 --- a/client/src/pages/Pages.css +++ b/client/src/pages/Pages.css @@ -531,3 +531,13 @@ transform: none; } } + +.disclaimer { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + font-size: 0.76rem; + color: var(--text-muted); + text-align: center; + line-height: 1.7; +} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..3cd91a9 --- /dev/null +++ b/client/src/pages/SettingsPage.tsx @@ -0,0 +1,102 @@ +import Screen from '../components/Screen'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { apiClient, ModelInfo } from '../services/api'; +import { FEATURES } from '../features'; +import './Settings.css'; + +function SettingsPage() { + const navigate = useNavigate(); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!FEATURES.ai) { + setLoading(false); + return; + } + apiClient + .getModels() + .then(setModels) + .catch(() => setModels([])) + .finally(() => setLoading(false)); + }, []); + + const handleLogout = async () => { + await apiClient.logout(); + navigate('/login'); + }; + + return ( + +

设置

+ + {FEATURES.ai && ( +
+

AI 模型

+

+ 模型清单与优先级由后端 backend/.env 决定。 + 填入对应厂商的密钥后,模型会自动变为可用; + AI_MODEL_CHAIN 控制自动模式下的尝试顺序。 +

+ + {loading ? ( +

加载中…

+ ) : models.length === 0 ? ( +

无法获取模型列表。

+ ) : ( + + + + + + + + + + + {models.map((m) => ( + + + + + + + ))} + +
ID模型上下文状态
+ {m.id} + {m.default && 默认} + {m.model}{(m.contextWindow / 1000).toLocaleString()}k + + {m.configured ? '已配置' : '缺少密钥'} + +
+ )} +
+ )} + +
+

数据与隐私

+
    +
  • 健康数据保存在自建数据库中,不上传第三方服务。
  • +
  • + Garmin 账号通过 OAuth 令牌授权,密码不会被保存;令牌约一年后过期, + 届时在「同步」页重新绑定一次即可。 +
  • +
  • 本站登录密码以 PBKDF2 加盐哈希存储,无法还原。
  • + +
+
+ +
+

账户

+ +
+
+ ); +} + +export default SettingsPage; diff --git a/client/src/pages/SleepPage.tsx b/client/src/pages/SleepPage.tsx new file mode 100644 index 0000000..2362c03 --- /dev/null +++ b/client/src/pages/SleepPage.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'framework7-react'; +import { apiClient, errorMessage, HealthDay } from '../services/api'; +import Chart from '../components/charts/Chart'; +import StatTile from '../components/charts/StatTile'; +import Skeleton from '../components/Skeleton'; +import Screen from '../components/Screen'; + +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 SleepPage() { + const [days, setDays] = useState([]); + // 14 by default: the stacked chart needs bars wide enough to read the + // thinnest stage and to give hover a ~24px hit target. Longer windows stay + // available for the trend, where density matters less. + const [range, setRange] = useState(14); + 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 SleepPage; diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx new file mode 100644 index 0000000..0bbfcca --- /dev/null +++ b/client/src/pages/SyncPage.tsx @@ -0,0 +1,366 @@ +import Screen from '../components/Screen'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus, +} from '../services/api'; +import './DataSync.css'; + +const POLL_MS = 2000; + +function SyncPage() { + const [syncStatus, setSyncStatus] = useState(null); + const [hasToken, setHasToken] = useState(null); + + // Garmin login (only needed until a token is stored) + const [password, setPassword] = useState(''); + const [session, setSession] = useState(null); + const [loginState, setLoginState] = useState(null); + const [code, setCode] = useState(''); + const [codeSubmitted, setCodeSubmitted] = useState(false); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + + const pollRef = useRef(null); + + const stopPolling = useCallback(() => { + if (pollRef.current) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + const loadSyncStatus = useCallback(async () => { + try { + setSyncStatus(await apiClient.getGarminSyncStatus()); + } catch (err) { + // A failed status poll should not blank the page. + console.error('Failed to load sync status:', err); + } + }, []); + + useEffect(() => { + // A backfill outlives the page, so a reload must pick the progress back up. + apiClient.getGarminSyncStatus().then((s) => { + setSyncStatus(s); + if (s.status === 'syncing') beginSyncPolling(); + }).catch(() => undefined); + apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false)); + return stopPolling; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // --- Garmin login ------------------------------------------------------- + const startLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setMessage(''); + if (!password) { + setError('请输入 Garmin 密码'); + return; + } + + setLoading(true); + try { + const sid = await apiClient.startGarminLogin(password); + // The password is only ever needed for this one request. + setPassword(''); + setSession(sid); + setLoginState('starting'); + setCodeSubmitted(false); + beginPolling(sid); + } catch (err: any) { + setError(errorMessage(err, '登录失败')); + } finally { + setLoading(false); + } + }; + + const beginPolling = (sid: string) => { + stopPolling(); + pollRef.current = window.setInterval(async () => { + try { + const { status, error: loginError } = await apiClient.getGarminLoginStatus(sid); + setLoginState(status); + + if (status === 'done') { + stopPolling(); + setSession(null); + setHasToken(true); + setMessage('Garmin 登录成功,之后同步不再需要密码或验证码。'); + } else if (status === 'failed') { + stopPolling(); + setSession(null); + setCodeSubmitted(false); + setError(loginError || '登录失败,请重试'); + } + } catch (err: any) { + stopPolling(); + setSession(null); + setError(errorMessage(err, '登录状态查询失败')); + } + }, POLL_MS); + }; + + const submitCode = async (e: React.FormEvent) => { + e.preventDefault(); + if (!session || !code.trim()) return; + + setError(''); + setLoading(true); + try { + const { ok, message: msg } = await apiClient.submitGarminMfa(session, code.trim()); + if (ok) { + setCodeSubmitted(true); + setCode(''); + } else { + setError(msg); + } + } catch (err: any) { + setError(errorMessage(err, '验证码提交失败')); + } finally { + setLoading(false); + } + }; + + const cancelLogin = async () => { + if (session) { + try { + await apiClient.cancelGarminLogin(session); + } catch { + // Cancelling is best-effort; the session expires on its own anyway. + } + } + stopPolling(); + setSession(null); + setLoginState(null); + setCode(''); + setCodeSubmitted(false); + }; + + // --- sync --------------------------------------------------------------- + const handleSync = async (days: number) => { + setError(''); + setMessage(''); + setLoading(true); + try { + await apiClient.syncGarminData(days); + await loadSyncStatus(); + beginSyncPolling(); + } catch (err: any) { + setError(errorMessage(err, '同步失败')); + } finally { + setLoading(false); + } + }; + + // The sync runs in the background, so the page follows it by polling + // rather than by holding a request open for the whole backfill. + const beginSyncPolling = () => { + stopPolling(); + pollRef.current = window.setInterval(async () => { + try { + const s = await apiClient.getGarminSyncStatus(); + setSyncStatus(s); + if (s.status !== 'syncing') { + stopPolling(); + if (s.status === 'error') setError(s.lastError || '同步失败'); + else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`); + } + } catch { + stopPolling(); + } + }, 2000); + }; + + const statusLabel: Record = { + idle: '就绪', + syncing: '正在同步…', + error: '上次同步失败', + }; + + const syncing = syncStatus?.status === 'syncing'; + const busy = loading || syncing; + const awaitingCode = loginState === 'awaiting_code' || codeSubmitted; + const current = syncStatus?.progressCurrent ?? 0; + const total = syncStatus?.progressTotal ?? 0; + const pct = total > 0 ? Math.round((current / total) * 100) : 0; + + return ( + +

数据同步

+

从 Garmin Connect 拉取最近 7 天的健康数据

+ +
+
+

同步状态

+ {syncStatus ? ( +
+
+ 状态 + + {statusLabel[syncStatus.status] ?? syncStatus.status} + +
+
+ 最后同步 + + {parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN') + ?? '从未同步'} + +
+
+ 已同步天数 + {syncStatus.recordsSynced} +
+ {syncStatus.lastError && ( +
+ 错误 + {syncStatus.lastError} +
+ )} +
+ ) : ( +

加载中…

+ )} +
+ + {/* Step 1 — link the Garmin account, once. */} + {hasToken === false && !session && ( +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + disabled={loading} + /> +

+ 只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年), + 密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。 +

+
+ +
+ )} + + {/* Step 2 — the two-factor code. */} + {session && ( +
+ {loginState === 'starting' && !codeSubmitted && ( +

正在连接 Garmin…

+ )} + + {awaitingCode && ( +
+

输入验证码

+

+ Garmin 已向你的手机或邮箱发送了 6 位验证码,请在下方填写。 +

+
+ setCode(e.target.value)} + placeholder="6 位数字" + className="code-input" + disabled={loading || codeSubmitted} + autoFocus + /> +
+
+ + +
+
+ )} + + {loginState === 'finishing' && ( +

验证通过,正在完成登录…

+ )} +
+ )} + + {/* Step 3 — sync, once linked. */} + {hasToken === true && ( +
+

拉取数据

+

+ 已绑定 Garmin 账号,同步无需密码。首次建议回补一段历史, + 之后日常只需拉最近 7 天。 +

+ + {syncing && total > 0 ? ( +
+
+ 正在同步… + {current} / {total} 天 +
+
+
+
+

+ 在后台运行,可以离开本页。约每天 3 秒, + {total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''} +

+
+ ) : ( +
+ {[7, 30, 90, 365].map((d) => ( + + ))} +
+ )} +
+ )} + + {error &&
{error}
} + {message &&
{message}
} + +
+

关于数据同步

+
    +
  • 每次同步获取最近 7 天的每日汇总与运动记录
  • +
  • 同一天重复同步会更新原有记录,不会产生重复数据
  • +
  • 数据保存在本机数据库,不经过第三方服务
  • +
  • Garmin 授权令牌约一年过期,届时重新绑定一次即可
  • +
+
+
+
+ ); +} + +export default SyncPage; diff --git a/client/src/pages/Dashboard.css b/client/src/pages/Today.css similarity index 100% rename from client/src/pages/Dashboard.css rename to client/src/pages/Today.css diff --git a/client/src/pages/TodayPage.tsx b/client/src/pages/TodayPage.tsx new file mode 100644 index 0000000..6836da8 --- /dev/null +++ b/client/src/pages/TodayPage.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'framework7-react'; +import { apiClient, errorMessage, HealthDay } from '../services/api'; +import Screen from '../components/Screen'; +import Ring from '../components/charts/Ring'; +import MetricCard from '../components/charts/MetricCard'; +import MetricStrip from '../components/charts/MetricStrip'; +import Skeleton from '../components/Skeleton'; +import { useCountUp } from '../lib/motion'; +import './Today.css'; + +const DAYS = 30; + +function StepHero({ today, history }: { today: HealthDay; history: HealthDay[] }) { + const goal = today.stepGoal ?? null; + const steps = today.steps ?? null; + const progress = steps != null && goal ? steps / goal : null; + const animated = useCountUp(steps); + + const week = history.slice(-7).map((d) => d.steps).filter((v): v is number => v != null); + const weekAvg = week.length + ? Math.round(week.reduce((a, b) => a + b, 0) / week.length) + : null; + const remaining = steps != null && goal ? goal - steps : null; + + return ( +
+ + + {steps == null ? '—' : Math.round(animated ?? steps).toLocaleString()} + + + + +
+
+ {progress == null + ? '今日暂无步数记录' + : progress >= 1 + ? '今日目标已完成' + : `距目标还差 ${remaining!.toLocaleString()} 步`} +
+
+
目标
{goal ? goal.toLocaleString() : '—'}
+
近 7 日均
{weekAvg ? weekAvg.toLocaleString() : '—'}
+
完成度
{progress != null ? `${Math.round(progress * 100)}%` : '—'}
+
+
+
+ ); +} + +function TodayPage() { + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const load = async () => { + try { + const end = new Date(); + const start = new Date(end.getTime() - (DAYS - 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(); + }, []); + + const today = days[days.length - 1]; + + return ( + + {loading && ( + <> +