[阶段10] 前端以 Framework7 重建,采用 iOS 原生形态
按要求废弃手写外壳,改用 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 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,9 @@ JWT_SECRET=dev_secret_change_me
|
|||||||
JWT_EXPIRY_DAYS=7
|
JWT_EXPIRY_DAYS=7
|
||||||
|
|
||||||
# --- CORS (comma-separated allowed front-end origins) ---
|
# --- 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
|
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
# --- AI models (text-only, large context) ---
|
# --- AI models (text-only, large context) ---
|
||||||
|
|||||||
49
client/.attic/App.old.tsx
Normal file
49
client/.attic/App.old.tsx
Normal file
@@ -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 (
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/sync" element={<DataSync />} />
|
||||||
|
<Route path="/health" element={<Health />} />
|
||||||
|
<Route path="/daily" element={<Daily />} />
|
||||||
|
<Route path="/trends" element={<Trends />} />
|
||||||
|
<Route path="/sleep" element={<Sleep />} />
|
||||||
|
<Route path="/achievements" element={<Achievements />} />
|
||||||
|
{FEATURES.ai && (
|
||||||
|
<Route path="/recommendations" element={<Recommendations />} />
|
||||||
|
)}
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
181
client/.attic/Layout.css
Normal file
181
client/.attic/Layout.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
131
client/.attic/Layout.tsx
Normal file
131
client/.attic/Layout.tsx
Normal file
@@ -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 <html> and the tokens swap in one place. */
|
||||||
|
function useTheme(): [Theme, (t: Theme) => void] {
|
||||||
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (localStorage.getItem('ghl_theme') as Theme) || 'system'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (theme === 'system') root.removeAttribute('data-theme');
|
||||||
|
else root.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('ghl_theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return [theme, setTheme];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="layout">
|
||||||
|
<header className="header">
|
||||||
|
<div className="header-inner">
|
||||||
|
<Link to="/" className="logo">Garmin Health Lab</Link>
|
||||||
|
|
||||||
|
{/* Wide screens show every destination in one row; narrow screens use
|
||||||
|
the bottom bar instead, where the thumb already is. */}
|
||||||
|
<nav className="nav" aria-label="主导航">
|
||||||
|
{[...NAV, ...SECONDARY].map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`nav-link ${isActive(location.pathname, item.path) ? 'active' : ''}`}
|
||||||
|
aria-current={isActive(location.pathname, item.path) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
<nav className="header-secondary" aria-label="次级导航">
|
||||||
|
{SECONDARY.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`icon-link ${isActive(location.pathname, item.path) ? 'active' : ''}`}
|
||||||
|
title={item.label}
|
||||||
|
aria-label={item.label}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{item.icon}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<button
|
||||||
|
className="theme-toggle"
|
||||||
|
onClick={cycle}
|
||||||
|
aria-label={`切换主题,当前${themeLabel}`}
|
||||||
|
>
|
||||||
|
{themeLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="content">{children}</main>
|
||||||
|
|
||||||
|
<nav className="tabbar" aria-label="主导航">
|
||||||
|
{NAV.map((item) => {
|
||||||
|
const active = isActive(location.pathname, item.path);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`tab ${active ? 'active' : ''}`}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span className="tab-icon" aria-hidden="true">{item.icon}</span>
|
||||||
|
<span className="tab-label">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -4,19 +4,22 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"proxy": "http://localhost:5000",
|
"proxy": "http://localhost:5000",
|
||||||
"dependencies": {
|
"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": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.14.2",
|
"react-router-dom": "^6.14.2",
|
||||||
"axios": "^1.5.0",
|
|
||||||
"recharts": "^2.8.0",
|
"recharts": "^2.8.0",
|
||||||
"tailwindcss": "^3.3.2",
|
"tailwindcss": "^3.3.2"
|
||||||
"date-fns": "^2.30.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.14",
|
"@types/react": "^18.2.14",
|
||||||
"@types/react-dom": "^18.2.6",
|
"@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": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|||||||
@@ -1,46 +1,103 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { useEffect, useState } from 'react';
|
||||||
import Layout from './components/Layout';
|
import { App as F7App, View, Views, Toolbar, Link } from 'framework7-react';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import Framework7 from 'framework7/lite-bundle';
|
||||||
import Login from './pages/Login';
|
import Framework7React from 'framework7-react';
|
||||||
import Dashboard from './pages/Dashboard';
|
|
||||||
import DataSync from './pages/DataSync';
|
import routes from './routes';
|
||||||
import Trends from './pages/Trends';
|
|
||||||
import Recommendations from './pages/Recommendations';
|
import 'framework7/css/bundle';
|
||||||
import Settings from './pages/Settings';
|
// The icon font F7's iconIos/iconMd props reference; without it the props
|
||||||
import Sleep from './pages/Sleep';
|
// render as their raw names.
|
||||||
import Achievements from './pages/Achievements';
|
import 'framework7-icons';
|
||||||
import Daily from './pages/Daily';
|
import './f7theme.css';
|
||||||
import { FEATURES } from './features';
|
|
||||||
|
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 <html> 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() {
|
function App() {
|
||||||
return (
|
useTheme();
|
||||||
<Router>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
|
|
||||||
<Route
|
return (
|
||||||
path="*"
|
<F7App
|
||||||
element={
|
name="Garmin Health Lab"
|
||||||
<ProtectedRoute>
|
// iOS only: the Material variants would read as a different app on the
|
||||||
<Layout>
|
// same screen, and the reference this is modelled on is an iOS app.
|
||||||
<Routes>
|
theme="ios"
|
||||||
<Route path="/" element={<Dashboard />} />
|
darkMode="auto"
|
||||||
<Route path="/sync" element={<DataSync />} />
|
routes={routes}
|
||||||
<Route path="/daily" element={<Daily />} />
|
view={{ browserHistory: true, browserHistorySeparator: '' }}
|
||||||
<Route path="/trends" element={<Trends />} />
|
touch={{ tapHold: true }}
|
||||||
<Route path="/sleep" element={<Sleep />} />
|
>
|
||||||
<Route path="/achievements" element={<Achievements />} />
|
<Views tabs className="safe-areas">
|
||||||
{FEATURES.ai && (
|
<Toolbar tabbar icons bottom>
|
||||||
<Route path="/recommendations" element={<Recommendations />} />
|
{TABS.map((tab) => (
|
||||||
)}
|
<Link
|
||||||
<Route path="/settings" element={<Settings />} />
|
key={tab.id}
|
||||||
</Routes>
|
tabLink={`#view-${tab.id}`}
|
||||||
</Layout>
|
tabLinkActive={tab.id === 'today'}
|
||||||
</ProtectedRoute>
|
iconIos={`f7:${tab.icon}`}
|
||||||
}
|
iconMd={`f7:${tab.icon}`}
|
||||||
/>
|
text={tab.label}
|
||||||
</Routes>
|
/>
|
||||||
</Router>
|
))}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{/* Each tab keeps its own navigation stack, so pushing a detail screen
|
||||||
|
inside 健康 does not disturb where 趋势 was left. */}
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<View
|
||||||
|
key={tab.id}
|
||||||
|
id={`view-${tab.id}`}
|
||||||
|
name={tab.id}
|
||||||
|
main={tab.id === 'today'}
|
||||||
|
tab
|
||||||
|
tabActive={tab.id === 'today'}
|
||||||
|
url={tab.path}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Views>
|
||||||
|
</F7App>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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 <html> and the tokens swap in one place. */
|
|
||||||
function useTheme(): [Theme, (t: Theme) => void] {
|
|
||||||
const [theme, setTheme] = useState<Theme>(
|
|
||||||
() => (localStorage.getItem('ghl_theme') as Theme) || 'system'
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
if (theme === 'system') root.removeAttribute('data-theme');
|
|
||||||
else root.setAttribute('data-theme', theme);
|
|
||||||
localStorage.setItem('ghl_theme', theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return [theme, setTheme];
|
|
||||||
}
|
|
||||||
|
|
||||||
function Layout({ children }: LayoutProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
const [theme, setTheme] = useTheme();
|
|
||||||
|
|
||||||
const cycle = () =>
|
|
||||||
setTheme(theme === 'system' ? 'light' : theme === 'light' ? 'dark' : 'system');
|
|
||||||
|
|
||||||
const themeLabel = { system: '跟随系统', light: '浅色', dark: '深色' }[theme];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="layout">
|
|
||||||
<header className="header">
|
|
||||||
<div className="header-inner">
|
|
||||||
<Link to="/" className="logo">Garmin Health Lab</Link>
|
|
||||||
|
|
||||||
<nav className="nav" aria-label="主导航">
|
|
||||||
{NAV.map((item) => {
|
|
||||||
const active =
|
|
||||||
item.path === '/'
|
|
||||||
? location.pathname === '/'
|
|
||||||
: location.pathname.startsWith(item.path);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className={`nav-link ${active ? 'active' : ''}`}
|
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="theme-toggle"
|
|
||||||
onClick={cycle}
|
|
||||||
title={`主题:${themeLabel}`}
|
|
||||||
aria-label={`切换主题,当前${themeLabel}`}
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? '深色' : theme === 'light' ? '浅色' : '自动'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="content">{children}</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
470
client/src/components/Screen.css
Normal file
470
client/src/components/Screen.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
64
client/src/components/Screen.tsx
Normal file
64
client/src/components/Screen.tsx
Normal file
@@ -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 `<Page>` 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 <Page />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Navbar large={large} transparent={large} title={title} subtitle={subtitle} backLink={backLink ? '返回' : undefined}>
|
||||||
|
<NavRight>
|
||||||
|
{right ?? (
|
||||||
|
<>
|
||||||
|
<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />
|
||||||
|
<Link href="/sync/" iconIos="f7:arrow_2_circlepath" tooltip="同步" />
|
||||||
|
<Link href="/settings/" iconIos="f7:gear_alt" tooltip="设置" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavRight>
|
||||||
|
</Navbar>
|
||||||
|
|
||||||
|
<div className="page-inner">{children}</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Screen;
|
||||||
57
client/src/components/charts/BandBar.css
Normal file
57
client/src/components/charts/BandBar.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
79
client/src/components/charts/BandBar.tsx
Normal file
79
client/src/components/charts/BandBar.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`bandbar ${vertical ? 'vertical' : ''}`}
|
||||||
|
style={vertical ? { width: height } : { height }}
|
||||||
|
role="img"
|
||||||
|
aria-label={
|
||||||
|
value == null ? '暂无数据' : `当前 ${value},参考区间 ${range.goodFrom}~${range.goodTo}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{segments.map(({ band, size }, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`bandbar-seg tone-${band.tone}`}
|
||||||
|
style={vertical ? { height: `${size}%` } : { width: `${size}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{position != null && (
|
||||||
|
<span
|
||||||
|
className="bandbar-marker"
|
||||||
|
style={
|
||||||
|
vertical
|
||||||
|
? { bottom: settled ? `${position}%` : '0%' }
|
||||||
|
: { left: settled ? `${position}%` : '0%' }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BandBar;
|
||||||
124
client/src/components/charts/MetricCard.css
Normal file
124
client/src/components/charts/MetricCard.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
86
client/src/components/charts/MetricCard.tsx
Normal file
86
client/src/components/charts/MetricCard.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<number | null | undefined>;
|
||||||
|
/** 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 (
|
||||||
|
<Tag
|
||||||
|
className={`mcard ${onClick ? 'clickable' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
type={onClick ? 'button' : undefined}
|
||||||
|
>
|
||||||
|
<div className="mcard-head">
|
||||||
|
<span className="mcard-label">{label}</span>
|
||||||
|
{trend && trend.some((v) => v != null) ? (
|
||||||
|
<Sparkline values={trend} label={`${label}近期走势`} width={54} height={18} />
|
||||||
|
) : (
|
||||||
|
onClick && <span className="mcard-chevron" aria-hidden="true">›</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mcard-value">
|
||||||
|
{display}
|
||||||
|
{unit && numeric != null && <span className="mcard-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{verdict && numeric != null ? (
|
||||||
|
<>
|
||||||
|
<div className="mcard-verdict">
|
||||||
|
<span className={`mcard-tone tone-${verdict.band.tone}`}>
|
||||||
|
<span aria-hidden="true">{TONE_ICON[verdict.band.tone]}</span>
|
||||||
|
{verdict.band.label}
|
||||||
|
</span>
|
||||||
|
<span className="mcard-target">参考 {formatTarget(verdict.range)}</span>
|
||||||
|
</div>
|
||||||
|
<BandBar range={verdict.range} value={numeric} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
detail && <div className="mcard-detail">{detail}</div>
|
||||||
|
)}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MetricCard;
|
||||||
72
client/src/components/charts/MetricStrip.css
Normal file
72
client/src/components/charts/MetricStrip.css
Normal file
@@ -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); }
|
||||||
60
client/src/components/charts/MetricStrip.tsx
Normal file
60
client/src/components/charts/MetricStrip.tsx
Normal file
@@ -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 (
|
||||||
|
<section className="strip-card">
|
||||||
|
{title && <h3 className="strip-title">{title}</h3>}
|
||||||
|
<div className="strip">
|
||||||
|
{items.map((item) => {
|
||||||
|
const numeric = typeof item.value === 'number' ? item.value : null;
|
||||||
|
const verdict = item.metric ? classify(item.metric, numeric) : null;
|
||||||
|
return (
|
||||||
|
<div className="strip-item" key={item.label}>
|
||||||
|
<span className="strip-icon" aria-hidden="true">{item.icon}</span>
|
||||||
|
<div className="strip-meter">
|
||||||
|
{verdict ? (
|
||||||
|
<BandBar range={verdict.range} value={numeric} vertical height={5} />
|
||||||
|
) : (
|
||||||
|
<span className="strip-meter-empty" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="strip-value">
|
||||||
|
{numeric == null
|
||||||
|
? '—'
|
||||||
|
: numeric.toLocaleString(undefined, {
|
||||||
|
maximumFractionDigits: item.decimals ?? 0,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="strip-unit">{item.unit ?? item.label}</div>
|
||||||
|
{verdict && (
|
||||||
|
<div className={`strip-tone tone-${verdict.band.tone}`}>
|
||||||
|
{verdict.band.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MetricStrip;
|
||||||
105
client/src/f7theme.css
Normal file
105
client/src/f7theme.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
@import './theme.css';
|
@import './theme.css';
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
html, body, #root {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
width: 100%;
|
||||||
'Hiragino Sans GB', 'Microsoft YaHei', Roboto, sans-serif;
|
height: 100%;
|
||||||
-webkit-font-smoothing: antialiased;
|
margin: 0;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
padding: 0;
|
||||||
background: var(--surface-0);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
154
client/src/lib/ranges.ts
Normal file
154
client/src/lib/ranges.ts
Normal file
@@ -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<string, Range> = {
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
209
client/src/pages/AchievementsPage.tsx
Normal file
209
client/src/pages/AchievementsPage.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
running: '跑步',
|
||||||
|
cycling: '骑行',
|
||||||
|
walking: '步行',
|
||||||
|
hiking: '徒步',
|
||||||
|
swimming: '游泳',
|
||||||
|
table_tennis: '乒乓球',
|
||||||
|
strength_training: '力量训练',
|
||||||
|
indoor_cycling: '室内骑行',
|
||||||
|
treadmill_running: '跑步机',
|
||||||
|
fitness_equipment: '健身器械',
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = (key: string | null) =>
|
||||||
|
key ? ACTIVITY_LABEL[key] ?? key.replace(/_/g, ' ') : '—';
|
||||||
|
|
||||||
|
const date = (value: string | null) => (value ? value.slice(0, 10) : '—');
|
||||||
|
|
||||||
|
function AchievementsPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>('badges');
|
||||||
|
const [badges, setBadges] = useState<Badge[]>([]);
|
||||||
|
const [records, setRecords] = useState<PersonalRecord[]>([]);
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [b, r, a] = await Promise.all([
|
||||||
|
apiClient.getBadges(),
|
||||||
|
apiClient.getPersonalRecords(),
|
||||||
|
apiClient.getActivities(),
|
||||||
|
]);
|
||||||
|
setBadges(b);
|
||||||
|
setRecords(r);
|
||||||
|
setActivities(a);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(errorMessage(err, '加载失败'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Screen title="成就" subtitle="奖励徽章、个人纪录与运动记录">
|
||||||
|
<h2>成就</h2>
|
||||||
|
<Skeleton count={4} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badges cluster heavily by year, which is the only grouping that reads.
|
||||||
|
const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => {
|
||||||
|
const year = b.earned_date ? b.earned_date.slice(0, 4) : '未知';
|
||||||
|
(acc[year] ||= []).push(b);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const years = Object.keys(byYear).sort().reverse();
|
||||||
|
|
||||||
|
const totalPoints = badges.reduce((sum, b) => sum + (b.points ?? 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="成就" subtitle="奖励徽章、个人纪录与运动记录">
|
||||||
|
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="tile-grid" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<StatTile label="奖励徽章" value={badges.length} unit="个" />
|
||||||
|
<StatTile
|
||||||
|
label="徽章积分"
|
||||||
|
value={totalPoints || null}
|
||||||
|
detail={totalPoints ? undefined : '该账号未记录积分'}
|
||||||
|
/>
|
||||||
|
<StatTile label="个人纪录" value={records.length} unit="项" />
|
||||||
|
<StatTile label="运动记录" value={activities.length} unit="条" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="metric-tabs">
|
||||||
|
{([
|
||||||
|
['badges', `奖励 (${badges.length})`],
|
||||||
|
['records', `个人纪录 (${records.length})`],
|
||||||
|
['activities', `运动 (${activities.length})`],
|
||||||
|
] as Array<[Tab, string]>).map(([id, text]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
className={`metric-tab ${tab === id ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab(id)}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'badges' && (
|
||||||
|
badges.length === 0 ? (
|
||||||
|
<p className="screen-note">还没有同步到徽章。</p>
|
||||||
|
) : (
|
||||||
|
years.map((year) => (
|
||||||
|
<section className="sec" key={year}>
|
||||||
|
<h3 className="sec-title">
|
||||||
|
{year === '未知' ? '未知年份' : `${year} 年`}
|
||||||
|
<span className="sec-count">{byYear[year].length} 个</span>
|
||||||
|
</h3>
|
||||||
|
<div className="badge-grid">
|
||||||
|
{byYear[year].map((b) => (
|
||||||
|
<div className="badge" key={b.id}>
|
||||||
|
<div className="badge-name">{b.name || b.badge_key}</div>
|
||||||
|
<div className="badge-meta">
|
||||||
|
{date(b.earned_date)}
|
||||||
|
{b.earned_count && b.earned_count > 1 && (
|
||||||
|
<span className="badge-count">×{b.earned_count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'records' && (
|
||||||
|
records.length === 0 ? (
|
||||||
|
<p className="screen-note">还没有同步到个人纪录。</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">活动</th>
|
||||||
|
<th scope="col">类型</th>
|
||||||
|
<th scope="col">数值</th>
|
||||||
|
<th scope="col">日期</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{records.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<th scope="row">{r.activity_name || '—'}</th>
|
||||||
|
<td>{label(r.activity_type)}</td>
|
||||||
|
<td className="num">
|
||||||
|
{r.value != null ? r.value.toLocaleString(undefined, {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}) : '—'}
|
||||||
|
</td>
|
||||||
|
<td>{date(r.achieved_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'activities' && (
|
||||||
|
activities.length === 0 ? (
|
||||||
|
<p className="screen-note">所选区间内没有运动记录。</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">时间</th>
|
||||||
|
<th scope="col">类型</th>
|
||||||
|
<th scope="col">时长</th>
|
||||||
|
<th scope="col">距离</th>
|
||||||
|
<th scope="col">消耗</th>
|
||||||
|
<th scope="col">平均心率</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{activities.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<th scope="row">{a.start_time?.slice(0, 16).replace('T', ' ')}</th>
|
||||||
|
<td>{label(a.activity_type)}</td>
|
||||||
|
<td className="num">
|
||||||
|
{a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="num">{a.calories != null ? `${Math.round(a.calories)}` : '—'}</td>
|
||||||
|
<td className="num">{a.heart_rate_average ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AchievementsPage;
|
||||||
302
client/src/pages/DailyPage.tsx
Normal file
302
client/src/pages/DailyPage.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<HealthDay | null>(null);
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
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 (
|
||||||
|
<Screen title="每日数据">
|
||||||
|
|
||||||
|
<div className="day-nav">
|
||||||
|
<button className="day-btn" onClick={() => shift(-1)} aria-label="前一天">‹</button>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="day-input"
|
||||||
|
value={date}
|
||||||
|
max={iso(new Date())}
|
||||||
|
onChange={(e) => e.target.value && setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="day-btn"
|
||||||
|
onClick={() => shift(1)}
|
||||||
|
disabled={isToday}
|
||||||
|
aria-label="后一天"
|
||||||
|
>›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="day-summary">
|
||||||
|
{day ? `已记录 ${recorded} / ${totalFields} 项指标` : '该日无数据'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
{loading && <Skeleton count={8} variant="row" />}
|
||||||
|
|
||||||
|
{!loading && !error && !day && (
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>{date} 没有数据。可能当天未佩戴设备,或尚未同步到这一天。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && day && (
|
||||||
|
<>
|
||||||
|
<label className="toggle-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyRecorded}
|
||||||
|
onChange={(e) => setOnlyRecorded(e.target.checked)}
|
||||||
|
/>
|
||||||
|
只显示有数据的指标
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{GROUPS.map((g) => {
|
||||||
|
const fields = onlyRecorded
|
||||||
|
? g.fields.filter((f) => valueOf(day, f.key) != null)
|
||||||
|
: g.fields;
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="sec" key={g.title}>
|
||||||
|
<h3 className="sec-title">
|
||||||
|
{g.title}
|
||||||
|
<span className="sec-count">{fields.length} 项</span>
|
||||||
|
</h3>
|
||||||
|
<dl className="metric-list">
|
||||||
|
{fields.map((f) => {
|
||||||
|
const raw = valueOf(day, f.key);
|
||||||
|
return (
|
||||||
|
<div className="metric-row" key={String(f.key)}>
|
||||||
|
<dt>
|
||||||
|
{f.label}
|
||||||
|
{f.hint && <span className="metric-hint">{f.hint}</span>}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{raw == null ? (
|
||||||
|
<span className="metric-empty">未记录</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="metric-value">{fmt(f, raw)}</span>
|
||||||
|
{f.unit && <span className="metric-unit">{f.unit}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">
|
||||||
|
运动记录
|
||||||
|
<span className="sec-count">{activities.length} 条</span>
|
||||||
|
</h3>
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<p className="screen-note">当天没有运动记录。</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">开始</th>
|
||||||
|
<th scope="col">类型</th>
|
||||||
|
<th scope="col">时长</th>
|
||||||
|
<th scope="col">距离</th>
|
||||||
|
<th scope="col">消耗</th>
|
||||||
|
<th scope="col">平均心率</th>
|
||||||
|
<th scope="col">最高心率</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{activities.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<th scope="row">{a.start_time?.slice(11, 16)}</th>
|
||||||
|
<td>
|
||||||
|
{ACTIVITY_LABEL[a.activity_type] ??
|
||||||
|
a.activity_type?.replace(/_/g, ' ')}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{a.calories != null ? Math.round(a.calories) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="num">{a.heart_rate_average ?? '—'}</td>
|
||||||
|
<td className="num">{a.heart_rate_max ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DailyPage;
|
||||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||||
import Chart from '../components/charts/Chart';
|
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 Ring from '../components/charts/Ring';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import { useCountUp } from '../lib/motion';
|
import { useCountUp } from '../lib/motion';
|
||||||
@@ -20,22 +21,6 @@ function avg(values: Array<number | null | undefined>): number | null {
|
|||||||
return present.reduce((a, b) => a + b, 0) / present.length;
|
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. */
|
/** The one hero figure on this view: today's steps against the day's goal. */
|
||||||
function StepHero({ today, history }: { today: HealthDay; history: HealthDay[] }) {
|
function StepHero({ today, history }: { today: HealthDay; history: HealthDay[] }) {
|
||||||
const goal = today.stepGoal ?? null;
|
const goal = today.stepGoal ?? null;
|
||||||
@@ -149,15 +134,6 @@ function Dashboard() {
|
|||||||
const rows = days.map((d) => ({ ...d, date: short(d.date) }));
|
const rows = days.map((d) => ({ ...d, date: short(d.date) }));
|
||||||
|
|
||||||
const avgSteps = avg(days.map((d) => d.steps));
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -174,141 +150,117 @@ function Dashboard() {
|
|||||||
{/* Activity ---------------------------------------------------------- */}
|
{/* Activity ---------------------------------------------------------- */}
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<h3 className="section-title">活动</h3>
|
<h3 className="section-title">活动</h3>
|
||||||
<div className="tile-grid">
|
<div className="mcard-grid">
|
||||||
<StatTile
|
<MetricCard
|
||||||
|
metric="steps"
|
||||||
label="步数"
|
label="步数"
|
||||||
value={today.steps}
|
value={today.steps}
|
||||||
|
unit="步"
|
||||||
trend={days.map((d) => d.steps)}
|
trend={days.map((d) => d.steps)}
|
||||||
detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined}
|
|
||||||
progress={
|
|
||||||
today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<MetricCard
|
||||||
|
metric="intensityMinutes"
|
||||||
|
label="强度分钟"
|
||||||
|
value={today.intensityMinutes}
|
||||||
|
unit="分钟"
|
||||||
|
trend={days.map((d) => d.intensityMinutes)}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
metric="floorsAscended"
|
||||||
|
label="爬楼"
|
||||||
|
value={today.floorsAscended}
|
||||||
|
unit="层"
|
||||||
|
trend={days.map((d) => d.floorsAscended)}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
label="距离"
|
label="距离"
|
||||||
trend={days.map((d) => d.distanceMeters)}
|
|
||||||
value={today.distanceMeters != null ? today.distanceMeters / 1000 : null}
|
value={today.distanceMeters != null ? today.distanceMeters / 1000 : null}
|
||||||
unit="km"
|
unit="km"
|
||||||
decimals={2}
|
decimals={2}
|
||||||
/>
|
trend={days.map((d) => d.distanceMeters)}
|
||||||
<StatTile label="爬楼" value={round(today.floorsAscended)} unit="层" />
|
|
||||||
<StatTile
|
|
||||||
label="强度分钟"
|
|
||||||
trend={days.map((d) => d.intensityMinutes)}
|
|
||||||
value={today.intensityMinutes}
|
|
||||||
unit="分钟"
|
|
||||||
detail="中等以上强度"
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label="总消耗"
|
|
||||||
trend={days.map((d) => d.caloriesBurned)}
|
|
||||||
value={round(today.caloriesBurned)}
|
|
||||||
unit="kcal"
|
|
||||||
detail={
|
detail={
|
||||||
today.activeCalories != null
|
today.caloriesBurned != null
|
||||||
? `其中活动 ${Math.round(today.activeCalories)}`
|
? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
|
||||||
label="久坐"
|
|
||||||
trend={days.map((d) => d.sedentarySeconds)}
|
|
||||||
value={today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null}
|
|
||||||
unit="小时"
|
|
||||||
decimals={1}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Heart & stress ---------------------------------------------------- */}
|
{/* Heart & stress ---------------------------------------------------- */}
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<h3 className="section-title">心率与压力</h3>
|
<h3 className="section-title">心率与压力</h3>
|
||||||
<div className="tile-grid">
|
<div className="mcard-grid">
|
||||||
<StatTile
|
<MetricCard
|
||||||
|
metric="heartRate"
|
||||||
label="静息心率"
|
label="静息心率"
|
||||||
trend={days.map((d) => d.heartRate)}
|
|
||||||
value={today.heartRate}
|
value={today.heartRate}
|
||||||
unit="bpm"
|
unit="bpm"
|
||||||
status={rhrTone}
|
trend={days.map((d) => d.heartRate)}
|
||||||
statusLabel={rhrWord}
|
|
||||||
detail={avgRhr != null ? `30 日均 ${Math.round(avgRhr)}` : undefined}
|
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<MetricCard
|
||||||
label="心率区间"
|
metric="heartRateVariability"
|
||||||
value={
|
|
||||||
today.heartRateMin != null && today.heartRateMax != null
|
|
||||||
? `${today.heartRateMin}–${today.heartRateMax}`
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
unit="bpm"
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label="心率变异性"
|
label="心率变异性"
|
||||||
|
value={today.heartRateVariability}
|
||||||
|
unit="ms"
|
||||||
decimals={1}
|
decimals={1}
|
||||||
trend={days.map((d) => d.heartRateVariability)}
|
trend={days.map((d) => d.heartRateVariability)}
|
||||||
value={round(today.heartRateVariability)}
|
|
||||||
unit="ms"
|
|
||||||
detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
|
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<MetricCard
|
||||||
|
metric="stress"
|
||||||
label="平均压力"
|
label="平均压力"
|
||||||
trend={days.map((d) => d.stress)}
|
|
||||||
value={today.stress}
|
value={today.stress}
|
||||||
detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
|
trend={days.map((d) => d.stress)}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<MetricCard
|
||||||
label="身体电量"
|
metric="trainingReadiness"
|
||||||
value={
|
label="训练准备度"
|
||||||
today.bodyBatteryLow != null && today.bodyBatteryHigh != null
|
value={today.trainingReadiness}
|
||||||
? `${today.bodyBatteryLow}–${today.bodyBatteryHigh}`
|
unit="/100"
|
||||||
: null
|
trend={days.map((d) => d.trainingReadiness)}
|
||||||
}
|
|
||||||
detail={
|
|
||||||
today.bodyBatteryCharged != null
|
|
||||||
? `充 ${today.bodyBatteryCharged} / 耗 ${today.bodyBatteryDrained}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<StatTile label="训练准备度" value={today.trainingReadiness} unit="/100" />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Sleep & breathing -------------------------------------------------- */}
|
{/* Sleep & breathing -------------------------------------------------- */}
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<h3 className="section-title">睡眠与呼吸</h3>
|
<h3 className="section-title">睡眠与呼吸</h3>
|
||||||
<div className="tile-grid">
|
<div className="mcard-grid">
|
||||||
<StatTile
|
<MetricCard
|
||||||
|
metric="sleepDuration"
|
||||||
label="睡眠时长"
|
label="睡眠时长"
|
||||||
trend={days.map((d) => d.sleepDuration)}
|
|
||||||
value={today.sleepDuration}
|
value={today.sleepDuration}
|
||||||
unit="小时"
|
unit="小时"
|
||||||
decimals={1}
|
decimals={1}
|
||||||
status={sleepTone}
|
trend={days.map((d) => d.sleepDuration)}
|
||||||
statusLabel={sleepWord}
|
|
||||||
detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
|
|
||||||
/>
|
/>
|
||||||
<StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" />
|
<MetricCard
|
||||||
<StatTile
|
metric="sleepQuality"
|
||||||
label="血氧"
|
label="睡眠评分"
|
||||||
decimals={1}
|
value={today.sleepQuality}
|
||||||
trend={days.map((d) => d.spo2Avg)}
|
unit="/100"
|
||||||
value={round(today.spo2Avg)}
|
trend={days.map((d) => d.sleepQuality)}
|
||||||
unit="%"
|
|
||||||
detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label="呼吸频率"
|
|
||||||
decimals={1}
|
|
||||||
trend={days.map((d) => d.respirationAvg)}
|
|
||||||
value={round(today.respirationAvg)}
|
|
||||||
unit="次/分"
|
|
||||||
detail={
|
|
||||||
today.respirationMin != null && today.respirationMax != null
|
|
||||||
? `${today.respirationMin}–${today.respirationMax}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: '0.75rem' }} />
|
||||||
|
|
||||||
|
<MetricStrip
|
||||||
|
title="身体指标"
|
||||||
|
items={[
|
||||||
|
{ metric: 'heartRateVariability', icon: '💓', label: 'HRV',
|
||||||
|
value: today.heartRateVariability, unit: 'ms' },
|
||||||
|
{ metric: 'heartRate', icon: '❤️', label: '静息心率',
|
||||||
|
value: today.heartRate, unit: 'bpm' },
|
||||||
|
{ metric: 'respirationAvg', icon: '🫁', label: '呼吸',
|
||||||
|
value: today.respirationAvg, unit: '次/分', decimals: 1 },
|
||||||
|
{ metric: 'spo2Avg', icon: '🩸', label: '血氧',
|
||||||
|
value: today.spo2Avg, unit: '%' },
|
||||||
|
{ metric: 'bodyBatteryHigh', icon: '🔋', label: '身体电量',
|
||||||
|
value: today.bodyBatteryHigh, unit: '峰值' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<p className="section-link">
|
<p className="section-link">
|
||||||
<Link to="/sleep">查看睡眠分期详情 →</Link>
|
<Link to="/sleep">查看睡眠分期详情 →</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
195
client/src/pages/Health.tsx
Normal file
195
client/src/pages/Health.tsx
Normal file
@@ -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<HealthDay[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000);
|
||||||
|
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 (
|
||||||
|
<div className="page">
|
||||||
|
<h2>健康</h2>
|
||||||
|
<Skeleton count={8} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>健康</h2>
|
||||||
|
<div className="error-message">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>健康</h2>
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>还没有任何健康数据。</p>
|
||||||
|
<Link to="/sync" className="btn btn-primary">去同步 Garmin 数据</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>健康</h2>
|
||||||
|
<p className="subtitle">每项指标的最新值与参考区间</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<section className="section" key={section.title}>
|
||||||
|
<h3 className="section-title">{section.title}</h3>
|
||||||
|
<div className="mcard-grid">
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const { value, date } = latest(item.pick);
|
||||||
|
const stale = date != null && date !== days[days.length - 1].date;
|
||||||
|
return (
|
||||||
|
<MetricCard
|
||||||
|
key={item.label}
|
||||||
|
metric={item.metric}
|
||||||
|
label={item.label}
|
||||||
|
value={value}
|
||||||
|
unit={item.unit}
|
||||||
|
decimals={item.decimals}
|
||||||
|
trend={days.map(item.pick)}
|
||||||
|
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<p className="disclaimer">
|
||||||
|
参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Health;
|
||||||
189
client/src/pages/HealthPage.tsx
Normal file
189
client/src/pages/HealthPage.tsx
Normal file
@@ -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<HealthDay[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000);
|
||||||
|
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 (
|
||||||
|
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||||
|
<h2>健康</h2>
|
||||||
|
<Skeleton count={8} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||||
|
<h2>健康</h2>
|
||||||
|
<div className="screen-error">{error}</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days.length === 0) {
|
||||||
|
return (
|
||||||
|
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||||
|
<h2>健康</h2>
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>还没有任何健康数据。</p>
|
||||||
|
<Link href="/sync" className="btn btn-primary">去同步 Garmin 数据</Link>
|
||||||
|
</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||||
|
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<section className="sec" key={section.title}>
|
||||||
|
<h3 className="sec-title">{section.title}</h3>
|
||||||
|
<div className="mcard-grid">
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const { value, date } = latest(item.pick);
|
||||||
|
const stale = date != null && date !== days[days.length - 1].date;
|
||||||
|
return (
|
||||||
|
<MetricCard
|
||||||
|
key={item.label}
|
||||||
|
metric={item.metric}
|
||||||
|
label={item.label}
|
||||||
|
value={value}
|
||||||
|
unit={item.unit}
|
||||||
|
decimals={item.decimals}
|
||||||
|
trend={days.map(item.pick)}
|
||||||
|
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<p className="screen-disclaimer">
|
||||||
|
参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
|
||||||
|
</p>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HealthPage;
|
||||||
239
client/src/pages/LoginPage.tsx
Normal file
239
client/src/pages/LoginPage.tsx
Normal file
@@ -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<TabType>('login');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
// 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 (
|
||||||
|
<Page className="login-page" noNavbar noToolbar>
|
||||||
|
<div className="login-container">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-header">
|
||||||
|
<h1>🏃 Garmin Health Lab</h1>
|
||||||
|
<p>健康数据分析平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-tabs">
|
||||||
|
<button
|
||||||
|
className={`tab-button ${activeTab === 'login' ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('login');
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
{canRegister && (
|
||||||
|
<button
|
||||||
|
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('register');
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
{activeTab === 'login' && (
|
||||||
|
<form onSubmit={handleLogin} className="login-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="login-email">邮箱</label>
|
||||||
|
<input
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
value={loginEmail}
|
||||||
|
onChange={(e) => setLoginEmail(e.target.value)}
|
||||||
|
placeholder="example@example.com"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="login-password">密码</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
value={loginPassword}
|
||||||
|
onChange={(e) => setLoginPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="submit-button" disabled={loading}>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'register' && canRegister && (
|
||||||
|
<form onSubmit={handleRegister} className="login-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="reg-email">邮箱</label>
|
||||||
|
<input
|
||||||
|
id="reg-email"
|
||||||
|
type="email"
|
||||||
|
value={regEmail}
|
||||||
|
onChange={(e) => setRegEmail(e.target.value)}
|
||||||
|
placeholder="example@example.com"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="reg-garmin-email">Garmin 邮箱</label>
|
||||||
|
<input
|
||||||
|
id="reg-garmin-email"
|
||||||
|
type="email"
|
||||||
|
value={regGarminEmail}
|
||||||
|
onChange={(e) => setRegGarminEmail(e.target.value)}
|
||||||
|
placeholder="garmin@example.com"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="reg-password">密码</label>
|
||||||
|
<input
|
||||||
|
id="reg-password"
|
||||||
|
type="password"
|
||||||
|
value={regPassword}
|
||||||
|
onChange={(e) => setRegPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="reg-confirm-password">确认密码</label>
|
||||||
|
<input
|
||||||
|
id="reg-confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={regConfirmPassword}
|
||||||
|
onChange={(e) => setRegConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="submit-button" disabled={loading}>
|
||||||
|
{loading ? '注册中...' : '注册'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
13
client/src/pages/NotFoundPage.tsx
Normal file
13
client/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Screen from '../components/Screen';
|
||||||
|
|
||||||
|
function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<Screen title="找不到页面" backLink>
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>这个地址不存在。</p>
|
||||||
|
</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFoundPage;
|
||||||
@@ -531,3 +531,13 @@
|
|||||||
transform: none;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
102
client/src/pages/SettingsPage.tsx
Normal file
102
client/src/pages/SettingsPage.tsx
Normal file
@@ -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<ModelInfo[]>([]);
|
||||||
|
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 (
|
||||||
|
<Screen title="设置">
|
||||||
|
<h2>设置</h2>
|
||||||
|
|
||||||
|
{FEATURES.ai && (
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3>AI 模型</h3>
|
||||||
|
<p className="settings-hint">
|
||||||
|
模型清单与优先级由后端 <code>backend/.env</code> 决定。
|
||||||
|
填入对应厂商的密钥后,模型会自动变为可用;
|
||||||
|
<code>AI_MODEL_CHAIN</code> 控制自动模式下的尝试顺序。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="screen-note">加载中…</p>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<p className="screen-note">无法获取模型列表。</p>
|
||||||
|
) : (
|
||||||
|
<table className="model-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>模型</th>
|
||||||
|
<th>上下文</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{models.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>
|
||||||
|
<code>{m.id}</code>
|
||||||
|
{m.default && <span className="badge-default">默认</span>}
|
||||||
|
</td>
|
||||||
|
<td className="model-name">{m.model}</td>
|
||||||
|
<td>{(m.contextWindow / 1000).toLocaleString()}k</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${m.configured ? 'ok' : 'off'}`}>
|
||||||
|
{m.configured ? '已配置' : '缺少密钥'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3>数据与隐私</h3>
|
||||||
|
<ul className="settings-list">
|
||||||
|
<li>健康数据保存在自建数据库中,不上传第三方服务。</li>
|
||||||
|
<li>
|
||||||
|
Garmin 账号通过 OAuth 令牌授权,密码不会被保存;令牌约一年后过期,
|
||||||
|
届时在「同步」页重新绑定一次即可。
|
||||||
|
</li>
|
||||||
|
<li>本站登录密码以 PBKDF2 加盐哈希存储,无法还原。</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3>账户</h3>
|
||||||
|
<button className="btn btn-danger" onClick={handleLogout}>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
179
client/src/pages/SleepPage.tsx
Normal file
179
client/src/pages/SleepPage.tsx
Normal file
@@ -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 | undefined>): number | null {
|
||||||
|
const present = values.filter((v): v is number => v != null);
|
||||||
|
return present.length ? present.reduce((a, b) => a + b, 0) / present.length : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SleepPage() {
|
||||||
|
const [days, setDays] = useState<HealthDay[]>([]);
|
||||||
|
// 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<number>(
|
||||||
|
(sum, v) => sum + (v ?? 0), 0
|
||||||
|
);
|
||||||
|
const share = (v: number | null) =>
|
||||||
|
v == null || totalStages === 0 ? undefined : `占 ${Math.round((v / totalStages) * 100)}%`;
|
||||||
|
|
||||||
|
const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="睡眠" subtitle="分期、评分与夜间生理指标">
|
||||||
|
|
||||||
|
<div className="segmented-row">
|
||||||
|
<span className="segmented-label">范围</span>
|
||||||
|
<div className="segmented">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button key={r} className={r === range ? 'on' : ''} onClick={() => setRange(r)}>
|
||||||
|
{r} 天
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
{loading && (
|
||||||
|
<>
|
||||||
|
<Skeleton count={6} />
|
||||||
|
<div style={{ height: '1rem' }} />
|
||||||
|
<Skeleton count={2} variant="chart" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && nights.length === 0 && (
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>所选区间内没有睡眠记录。</p>
|
||||||
|
<Link href="/sync" className="btn btn-primary">去同步数据</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && nights.length > 0 && (
|
||||||
|
<>
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">{nights.length} 晚平均</h3>
|
||||||
|
<div className="tile-grid">
|
||||||
|
<StatTile label="总时长" value={hrs(avgDuration)} unit="小时" />
|
||||||
|
<StatTile label="睡眠评分" value={hrs(avgQuality, 0)} unit="/100" />
|
||||||
|
<StatTile label="深睡" value={hrs(avgDeep)} unit="小时" detail={share(avgDeep)} />
|
||||||
|
<StatTile label="浅睡" value={hrs(avgLight)} unit="小时" detail={share(avgLight)} />
|
||||||
|
<StatTile label="REM" value={hrs(avgRem)} unit="小时" detail={share(avgRem)} />
|
||||||
|
<StatTile label="夜间清醒" value={hrs(avgAwake)} unit="小时" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<div className="chart-grid one-col">
|
||||||
|
<Chart
|
||||||
|
title="睡眠分期"
|
||||||
|
unit="小时"
|
||||||
|
subtitle="每晚各阶段时长堆叠;总高度即当晚睡眠总时长"
|
||||||
|
data={rows}
|
||||||
|
type="stacked-bar"
|
||||||
|
height={280}
|
||||||
|
series={[
|
||||||
|
{ key: 'deep', label: '深睡', slot: 1, unit: '小时', decimals: 1 },
|
||||||
|
{ key: 'light', label: '浅睡', slot: 2, unit: '小时', decimals: 1 },
|
||||||
|
{ key: 'rem', label: 'REM', slot: 3, unit: '小时', decimals: 1 },
|
||||||
|
{ key: 'awake', label: '清醒', slot: 4, unit: '小时', decimals: 1 },
|
||||||
|
]}
|
||||||
|
footer="成人参考:深睡约占 13–23%,REM 约占 20–25%。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-grid">
|
||||||
|
<Chart
|
||||||
|
title="睡眠评分"
|
||||||
|
unit="/100"
|
||||||
|
data={rows}
|
||||||
|
type="area"
|
||||||
|
series={[{ key: 'quality', label: '评分', slot: 1 }]}
|
||||||
|
/>
|
||||||
|
<Chart
|
||||||
|
title="夜间血氧"
|
||||||
|
unit="%"
|
||||||
|
data={rows}
|
||||||
|
type="line"
|
||||||
|
series={[{ key: 'spo2', label: '血氧', slot: 1, unit: '%', decimals: 1 }]}
|
||||||
|
/>
|
||||||
|
<Chart
|
||||||
|
title="夜间呼吸频率"
|
||||||
|
unit="次/分"
|
||||||
|
data={rows}
|
||||||
|
type="line"
|
||||||
|
series={[
|
||||||
|
{ key: 'respiration', label: '呼吸', slot: 1, unit: '次/分', decimals: 1 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Chart
|
||||||
|
title="睡眠压力"
|
||||||
|
data={rows}
|
||||||
|
type="line"
|
||||||
|
series={[{ key: 'stress', label: '压力', slot: 1, decimals: 1 }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SleepPage;
|
||||||
366
client/src/pages/SyncPage.tsx
Normal file
366
client/src/pages/SyncPage.tsx
Normal file
@@ -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<SyncStatus | null>(null);
|
||||||
|
const [hasToken, setHasToken] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// Garmin login (only needed until a token is stored)
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [session, setSession] = useState<string | null>(null);
|
||||||
|
const [loginState, setLoginState] = useState<GarminLoginStatus | null>(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<number | null>(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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Screen title="数据同步">
|
||||||
|
<h2>数据同步</h2>
|
||||||
|
<p className="subtitle">从 Garmin Connect 拉取最近 7 天的健康数据</p>
|
||||||
|
|
||||||
|
<div className="sync-container">
|
||||||
|
<section className="status-card">
|
||||||
|
<h3>同步状态</h3>
|
||||||
|
{syncStatus ? (
|
||||||
|
<div className="status-info">
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="label">状态</span>
|
||||||
|
<span className={`value status-${syncStatus.status}`}>
|
||||||
|
{statusLabel[syncStatus.status] ?? syncStatus.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="label">最后同步</span>
|
||||||
|
<span className="value">
|
||||||
|
{parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
|
||||||
|
?? '从未同步'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="label">已同步天数</span>
|
||||||
|
<span className="value">{syncStatus.recordsSynced}</span>
|
||||||
|
</div>
|
||||||
|
{syncStatus.lastError && (
|
||||||
|
<div className="status-item error">
|
||||||
|
<span className="label">错误</span>
|
||||||
|
<span className="value">{syncStatus.lastError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="screen-note">加载中…</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Step 1 — link the Garmin account, once. */}
|
||||||
|
{hasToken === false && !session && (
|
||||||
|
<form className="sync-actions" onSubmit={startLogin}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="garmin-password">Garmin 密码</label>
|
||||||
|
<input
|
||||||
|
id="garmin-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<p className="field-hint">
|
||||||
|
只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年),
|
||||||
|
密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
|
||||||
|
{loading ? '正在连接…' : '绑定 Garmin 账号'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2 — the two-factor code. */}
|
||||||
|
{session && (
|
||||||
|
<section className="status-card mfa-card">
|
||||||
|
{loginState === 'starting' && !codeSubmitted && (
|
||||||
|
<p className="screen-note">正在连接 Garmin…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{awaitingCode && (
|
||||||
|
<form onSubmit={submitCode}>
|
||||||
|
<h3>输入验证码</h3>
|
||||||
|
<p className="field-hint" style={{ marginBottom: '1rem' }}>
|
||||||
|
Garmin 已向你的手机或邮箱发送了 6 位验证码,请在下方填写。
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
id="mfa-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={10}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="6 位数字"
|
||||||
|
className="code-input"
|
||||||
|
disabled={loading || codeSubmitted}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mfa-buttons">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={loading || codeSubmitted || !code.trim()}
|
||||||
|
>
|
||||||
|
{codeSubmitted ? '正在验证…' : '提交验证码'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loginState === 'finishing' && (
|
||||||
|
<p className="screen-note">验证通过,正在完成登录…</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3 — sync, once linked. */}
|
||||||
|
{hasToken === true && (
|
||||||
|
<section className="status-card">
|
||||||
|
<h3>拉取数据</h3>
|
||||||
|
<p className="field-hint" style={{ marginBottom: '0.9rem' }}>
|
||||||
|
已绑定 Garmin 账号,同步无需密码。首次建议回补一段历史,
|
||||||
|
之后日常只需拉最近 7 天。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{syncing && total > 0 ? (
|
||||||
|
<div className="progress-block">
|
||||||
|
<div className="progress-head">
|
||||||
|
<span>正在同步…</span>
|
||||||
|
<span className="progress-count">{current} / {total} 天</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="progress-bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={pct}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="field-hint">
|
||||||
|
在后台运行,可以离开本页。约每天 3 秒,
|
||||||
|
{total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sync-choices">
|
||||||
|
{[7, 30, 90, 365].map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => handleSync(d)}
|
||||||
|
className={`btn ${d === 7 ? 'btn-primary' : 'btn-plain'}`}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{d === 365 ? '回补一年' : `最近 ${d} 天`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
{message && <div className="screen-ok">{message}</div>}
|
||||||
|
|
||||||
|
<section className="info-box">
|
||||||
|
<h4>关于数据同步</h4>
|
||||||
|
<ul>
|
||||||
|
<li>每次同步获取最近 7 天的每日汇总与运动记录</li>
|
||||||
|
<li>同一天重复同步会更新原有记录,不会产生重复数据</li>
|
||||||
|
<li>数据保存在本机数据库,不经过第三方服务</li>
|
||||||
|
<li>Garmin 授权令牌约一年过期,届时重新绑定一次即可</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SyncPage;
|
||||||
163
client/src/pages/TodayPage.tsx
Normal file
163
client/src/pages/TodayPage.tsx
Normal file
@@ -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 (
|
||||||
|
<section className="hero">
|
||||||
|
<Ring
|
||||||
|
progress={progress}
|
||||||
|
label={`步数完成度 ${progress != null ? Math.round(progress * 100) : 0}%`}
|
||||||
|
>
|
||||||
|
<span className="hero-value">
|
||||||
|
{steps == null ? '—' : Math.round(animated ?? steps).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="hero-caption">步</span>
|
||||||
|
</Ring>
|
||||||
|
|
||||||
|
<div className="hero-facts">
|
||||||
|
<div className="hero-headline">
|
||||||
|
{progress == null
|
||||||
|
? '今日暂无步数记录'
|
||||||
|
: progress >= 1
|
||||||
|
? '今日目标已完成'
|
||||||
|
: `距目标还差 ${remaining!.toLocaleString()} 步`}
|
||||||
|
</div>
|
||||||
|
<dl className="hero-list">
|
||||||
|
<div><dt>目标</dt><dd>{goal ? goal.toLocaleString() : '—'}</dd></div>
|
||||||
|
<div><dt>近 7 日均</dt><dd>{weekAvg ? weekAvg.toLocaleString() : '—'}</dd></div>
|
||||||
|
<div><dt>完成度</dt><dd>{progress != null ? `${Math.round(progress * 100)}%` : '—'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodayPage() {
|
||||||
|
const [days, setDays] = useState<HealthDay[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end.getTime() - (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 (
|
||||||
|
<Screen title="今日" subtitle={today?.date}>
|
||||||
|
{loading && (
|
||||||
|
<>
|
||||||
|
<div className="hero-skeleton" aria-hidden="true" />
|
||||||
|
<Skeleton count={4} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && !today && (
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>还没有任何健康数据。</p>
|
||||||
|
<Link href="/sync/" className="button button-fill button-round">
|
||||||
|
去同步 Garmin 数据
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && today && (
|
||||||
|
<>
|
||||||
|
<StepHero today={today} history={days} />
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">活动</h3>
|
||||||
|
<div className="mcard-grid">
|
||||||
|
<MetricCard metric="steps" label="步数" value={today.steps} unit="步"
|
||||||
|
trend={days.map((d) => d.steps)} />
|
||||||
|
<MetricCard metric="intensityMinutes" label="强度分钟" value={today.intensityMinutes}
|
||||||
|
unit="分钟" trend={days.map((d) => d.intensityMinutes)} />
|
||||||
|
<MetricCard metric="floorsAscended" label="爬楼" value={today.floorsAscended}
|
||||||
|
unit="层" trend={days.map((d) => d.floorsAscended)} />
|
||||||
|
<MetricCard label="距离" unit="km" decimals={2}
|
||||||
|
value={today.distanceMeters != null ? today.distanceMeters / 1000 : null}
|
||||||
|
trend={days.map((d) => d.distanceMeters)}
|
||||||
|
detail={today.caloriesBurned != null
|
||||||
|
? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal` : undefined} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">心率与压力</h3>
|
||||||
|
<div className="mcard-grid">
|
||||||
|
<MetricCard metric="heartRate" label="静息心率" value={today.heartRate} unit="bpm"
|
||||||
|
trend={days.map((d) => d.heartRate)} />
|
||||||
|
<MetricCard metric="heartRateVariability" label="心率变异性"
|
||||||
|
value={today.heartRateVariability} unit="ms" decimals={1}
|
||||||
|
trend={days.map((d) => d.heartRateVariability)} />
|
||||||
|
<MetricCard metric="stress" label="平均压力" value={today.stress}
|
||||||
|
trend={days.map((d) => d.stress)} />
|
||||||
|
<MetricCard metric="trainingReadiness" label="训练准备度"
|
||||||
|
value={today.trainingReadiness} unit="/100"
|
||||||
|
trend={days.map((d) => d.trainingReadiness)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">睡眠</h3>
|
||||||
|
<div className="mcard-grid">
|
||||||
|
<MetricCard metric="sleepDuration" label="睡眠时长" value={today.sleepDuration}
|
||||||
|
unit="小时" decimals={1} trend={days.map((d) => d.sleepDuration)} />
|
||||||
|
<MetricCard metric="sleepQuality" label="睡眠评分" value={today.sleepQuality}
|
||||||
|
unit="/100" trend={days.map((d) => d.sleepQuality)} />
|
||||||
|
</div>
|
||||||
|
<p className="sec-link"><Link href="/sleep/">查看睡眠分期详情 →</Link></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<MetricStrip title="身体指标" items={[
|
||||||
|
{ metric: 'heartRateVariability', icon: '💓', label: 'HRV', value: today.heartRateVariability, unit: 'ms' },
|
||||||
|
{ metric: 'heartRate', icon: '❤️', label: '静息心率', value: today.heartRate, unit: 'bpm' },
|
||||||
|
{ metric: 'respirationAvg', icon: '🫁', label: '呼吸', value: today.respirationAvg, unit: '次/分', decimals: 1 },
|
||||||
|
{ metric: 'spo2Avg', icon: '🩸', label: '血氧', value: today.spo2Avg, unit: '%' },
|
||||||
|
{ metric: 'bodyBatteryHigh', icon: '🔋', label: '身体电量', value: today.bodyBatteryHigh, unit: '峰值' },
|
||||||
|
]} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TodayPage;
|
||||||
343
client/src/pages/TrendsPage.tsx
Normal file
343
client/src/pages/TrendsPage.tsx
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||||
|
import Chart, { Series } from '../components/charts/Chart';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import {
|
||||||
|
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
||||||
|
} from '../lib/aggregate';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ days: 30, label: '近一月' },
|
||||||
|
{ days: 91, label: '近一季' },
|
||||||
|
{ days: 182, label: '近半年' },
|
||||||
|
{ days: 365, label: '近一年' },
|
||||||
|
{ days: 730, label: '近两年' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HIDDEN_KEY = 'ghl_hidden_metrics';
|
||||||
|
|
||||||
|
/** Each group is one chart. Metrics only share a chart when they share a
|
||||||
|
* scale and a unit — a chart never carries two y-scales. */
|
||||||
|
interface MetricGroup {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
unit?: string;
|
||||||
|
type: 'line' | 'bar' | 'area';
|
||||||
|
series: Series[];
|
||||||
|
/** Optional transform, e.g. metres to kilometres. */
|
||||||
|
scale?: Record<string, number>;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS: MetricGroup[] = [
|
||||||
|
{
|
||||||
|
id: 'steps', label: '步数', unit: '步', type: 'bar',
|
||||||
|
series: [{ key: 'steps', label: '步数', slot: 1, unit: '步' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'distance', label: '距离', unit: 'km', type: 'bar',
|
||||||
|
scale: { distanceMeters: 1 / 1000 },
|
||||||
|
series: [{ key: 'distanceMeters', label: '距离', slot: 1, unit: 'km', decimals: 2 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'calories', label: '能量消耗', unit: 'kcal', type: 'bar',
|
||||||
|
series: [
|
||||||
|
{ key: 'bmrCalories', label: '基础代谢', slot: 1, unit: 'kcal' },
|
||||||
|
{ key: 'activeCalories', label: '活动消耗', slot: 2, unit: 'kcal' },
|
||||||
|
],
|
||||||
|
note: '两者相加即当日总消耗。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'heart', label: '心率', unit: 'bpm', type: 'line',
|
||||||
|
series: [
|
||||||
|
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
|
||||||
|
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
|
||||||
|
{ key: 'heartRateMin', label: '最低', slot: 3, unit: 'bpm' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hrv', label: '心率变异性', unit: 'ms', type: 'area',
|
||||||
|
series: [{ key: 'heartRateVariability', label: 'HRV', slot: 1, unit: 'ms', decimals: 1 }],
|
||||||
|
note: 'HRV 反映自主神经恢复情况,持续偏低常与压力或训练过量相关。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stress', label: '压力', type: 'line',
|
||||||
|
series: [
|
||||||
|
{ key: 'stress', label: '平均', slot: 1 },
|
||||||
|
{ key: 'stressMax', label: '峰值', slot: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'battery', label: '身体电量', type: 'line',
|
||||||
|
series: [
|
||||||
|
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
|
||||||
|
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sleep', label: '睡眠时长', unit: '小时', type: 'area',
|
||||||
|
series: [{ key: 'sleepDuration', label: '时长', slot: 1, unit: '小时', decimals: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spo2', label: '血氧', unit: '%', type: 'line',
|
||||||
|
series: [
|
||||||
|
{ key: 'spo2Avg', label: '平均', slot: 1, unit: '%', decimals: 1 },
|
||||||
|
{ key: 'spo2Min', label: '最低', slot: 2, unit: '%' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'respiration', label: '呼吸频率', unit: '次/分', type: 'line',
|
||||||
|
series: [
|
||||||
|
{ key: 'respirationAvg', label: '平均', slot: 1, unit: '次/分', decimals: 1 },
|
||||||
|
{ key: 'respirationMax', label: '最高', slot: 2, unit: '次/分', decimals: 1 },
|
||||||
|
{ key: 'respirationMin', label: '最低', slot: 3, unit: '次/分', decimals: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'floors', label: '爬楼', unit: '层', type: 'bar',
|
||||||
|
series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
|
||||||
|
series: [{ key: 'intensityMinutes', label: '强度分钟', slot: 1, unit: '分钟' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sedentary', label: '久坐与活动时长', unit: '小时', type: 'bar',
|
||||||
|
scale: { sedentarySeconds: 1 / 3600, activeSeconds: 1 / 3600 },
|
||||||
|
series: [
|
||||||
|
{ key: 'sedentarySeconds', label: '久坐', slot: 1, unit: '小时', decimals: 1 },
|
||||||
|
{ key: 'activeSeconds', label: '活动', slot: 2, unit: '小时', decimals: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'training', label: '训练准备度', unit: '/100', type: 'area',
|
||||||
|
series: [{ key: 'trainingReadiness', label: '准备度', slot: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'endurance', label: '耐力分', type: 'area',
|
||||||
|
series: [{ key: 'enduranceScore', label: '耐力分', slot: 1 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function summarise(values: Array<number | null | undefined>) {
|
||||||
|
const present = values.filter((v): v is number => v != null);
|
||||||
|
if (!present.length) return null;
|
||||||
|
const sorted = [...present].sort((a, b) => a - b);
|
||||||
|
const mean = present.reduce((a, b) => a + b, 0) / present.length;
|
||||||
|
const mid = Math.floor(present.length / 2);
|
||||||
|
const delta =
|
||||||
|
present.length > 1
|
||||||
|
? present.slice(mid).reduce((a, b) => a + b, 0) / (present.length - mid) -
|
||||||
|
present.slice(0, mid).reduce((a, b) => a + b, 0) / Math.max(mid, 1)
|
||||||
|
: 0;
|
||||||
|
return { mean, min: sorted[0], max: sorted[sorted.length - 1], delta };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v: number) => {
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
|
||||||
|
return v.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function TrendsPage() {
|
||||||
|
const [days, setDays] = useState<HealthDay[]>([]);
|
||||||
|
const [range, setRange] = useState(365);
|
||||||
|
const [granularity, setGranularity] = useState<Granularity | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Which charts are hidden. Persisted: a selection that resets on every
|
||||||
|
// reload is not really a preference.
|
||||||
|
const [hidden, setHidden] = useState<Set<string>>(() => {
|
||||||
|
try {
|
||||||
|
return new Set<string>(JSON.parse(localStorage.getItem(HIDDEN_KEY) || '[]'));
|
||||||
|
} catch {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hidden]));
|
||||||
|
}, [hidden]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end.getTime() - (range - 1) * 86400000);
|
||||||
|
setDays(
|
||||||
|
await apiClient.getHealthSummary(
|
||||||
|
start.toISOString().slice(0, 10),
|
||||||
|
end.toISOString().slice(0, 10)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(errorMessage(err, '加载失败'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
const effective = granularity ?? suggestGranularity(days.length);
|
||||||
|
const visible = GROUPS.filter((g) => !hidden.has(g.id));
|
||||||
|
|
||||||
|
// Aggregated once for every metric, so all charts read the same slice — a
|
||||||
|
// filter row that scoped only some of them would be misleading.
|
||||||
|
const allKeys = useMemo(() => GROUPS.flatMap((g) => g.series.map((s) => s.key)), []);
|
||||||
|
const buckets = useMemo(
|
||||||
|
() => aggregate(days, effective, allKeys),
|
||||||
|
[days, effective, allKeys]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowsFor = (group: MetricGroup) =>
|
||||||
|
buckets.map((b) => {
|
||||||
|
const row: Record<string, any> = { date: b.label };
|
||||||
|
for (const s of group.series) {
|
||||||
|
const raw = b.values[s.key];
|
||||||
|
const factor = group.scale?.[s.key];
|
||||||
|
row[s.key] = raw == null ? null : factor ? raw * factor : raw;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setHidden((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const granLabel =
|
||||||
|
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="趋势">
|
||||||
|
|
||||||
|
<div className="segmented-row">
|
||||||
|
<span className="segmented-label">范围</span>
|
||||||
|
<div className="segmented">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button key={r.days} className={r.days === range ? 'on' : ''}
|
||||||
|
onClick={() => setRange(r.days)}>{r.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="segmented-row">
|
||||||
|
<span className="segmented-label">周期</span>
|
||||||
|
<div className="segmented">
|
||||||
|
{GRANULARITIES.filter((g) => g.days <= Math.max(range / 2, 1)).map((g) => (
|
||||||
|
<button key={g.id} className={g.id === effective ? 'on' : ''}
|
||||||
|
onClick={() => setGranularity(g.id)}>{g.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="metric-picker">
|
||||||
|
<div className="picker-head">
|
||||||
|
<span className="control-label">显示</span>
|
||||||
|
<div className="picker-actions">
|
||||||
|
<button className="link-button" onClick={() => setHidden(new Set())}>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="link-button"
|
||||||
|
onClick={() => setHidden(new Set(GROUPS.map((g) => g.id)))}
|
||||||
|
>
|
||||||
|
全不选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="picker-chips">
|
||||||
|
{GROUPS.map((g) => {
|
||||||
|
const on = !hidden.has(g.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
className={`chip ${on ? 'on' : 'off'}`}
|
||||||
|
onClick={() => toggle(g.id)}
|
||||||
|
aria-pressed={on}
|
||||||
|
>
|
||||||
|
{/* A mark, not colour alone, carries the on/off state. */}
|
||||||
|
<span className="chip-mark" aria-hidden="true">{on ? '✓' : '+'}</span>
|
||||||
|
{g.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
{loading && <Skeleton count={6} variant="chart" />}
|
||||||
|
|
||||||
|
{!loading && !error && visible.length === 0 && (
|
||||||
|
<p className="screen-note">所有指标都已隐藏,点上面的标签重新显示。</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && visible.length > 0 && (
|
||||||
|
<div className="chart-grid">
|
||||||
|
{visible.map((g) => {
|
||||||
|
const rows = rowsFor(g);
|
||||||
|
const primary = g.series[0];
|
||||||
|
const s = summarise(rows.map((r) => r[primary.key]));
|
||||||
|
/* Bars and areas both encode magnitude by extent, so both must
|
||||||
|
start at zero — which makes a year of monthly step averages,
|
||||||
|
all between 9.6k and 12.7k, render as near-identical shapes and
|
||||||
|
hides exactly the change the reader came for. Once days are
|
||||||
|
bucketed the question is "how is this trending", and that is a
|
||||||
|
line's job: it encodes position rather than extent, so a
|
||||||
|
non-zero axis is legitimate and the variation becomes visible.
|
||||||
|
Dense daily views switch for the same reason plus hit size. */
|
||||||
|
const aggregated = effective !== 'day';
|
||||||
|
const type =
|
||||||
|
aggregated || (g.type === 'bar' && rows.length > 90)
|
||||||
|
? ('line' as const)
|
||||||
|
: g.type;
|
||||||
|
const meanWord =
|
||||||
|
effective !== 'day' && isCumulative(primary.key) ? '日均' : '平均';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chart
|
||||||
|
key={g.id}
|
||||||
|
title={g.label}
|
||||||
|
unit={g.unit}
|
||||||
|
subtitle={
|
||||||
|
effective === 'day'
|
||||||
|
? undefined
|
||||||
|
: `每点为一个${granLabel}周期的日均值,共 ${rows.length} 个周期`
|
||||||
|
}
|
||||||
|
data={rows}
|
||||||
|
type={type}
|
||||||
|
series={g.series}
|
||||||
|
height={210}
|
||||||
|
footer={
|
||||||
|
s ? (
|
||||||
|
<span className="chart-stats">
|
||||||
|
<span>{meanWord} <b>{fmt(s.mean)}</b></span>
|
||||||
|
<span>低 <b>{fmt(s.min)}</b></span>
|
||||||
|
<span>高 <b>{fmt(s.max)}</b></span>
|
||||||
|
<span>后半段 <b>{s.delta >= 0 ? '+' : ''}{fmt(s.delta)}</b></span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
g.note
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrendsPage;
|
||||||
32
client/src/routes.ts
Normal file
32
client/src/routes.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Router } from 'framework7/types';
|
||||||
|
|
||||||
|
import TodayPage from './pages/TodayPage';
|
||||||
|
import HealthPage from './pages/HealthPage';
|
||||||
|
import DailyPage from './pages/DailyPage';
|
||||||
|
import TrendsPage from './pages/TrendsPage';
|
||||||
|
import AchievementsPage from './pages/AchievementsPage';
|
||||||
|
import SleepPage from './pages/SleepPage';
|
||||||
|
import SyncPage from './pages/SyncPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import NotFoundPage from './pages/NotFoundPage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secondary screens are reachable from more than one tab, so they are
|
||||||
|
* registered on every view's router rather than pinned to one — pushing 睡眠
|
||||||
|
* from 今日 should stay inside 今日's stack.
|
||||||
|
*/
|
||||||
|
const routes: Router.RouteParameters[] = [
|
||||||
|
{ path: '/', component: TodayPage },
|
||||||
|
{ path: '/health/', component: HealthPage },
|
||||||
|
{ path: '/daily/', component: DailyPage },
|
||||||
|
{ path: '/trends/', component: TrendsPage },
|
||||||
|
{ path: '/achievements/', component: AchievementsPage },
|
||||||
|
{ path: '/sleep/', component: SleepPage },
|
||||||
|
{ path: '/sync/', component: SyncPage },
|
||||||
|
{ path: '/settings/', component: SettingsPage },
|
||||||
|
{ path: '/login/', component: LoginPage },
|
||||||
|
{ path: '(.*)', component: NotFoundPage },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
|||||||
98
package-lock.json
generated
98
package-lock.json
generated
@@ -20,6 +20,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.5.0",
|
"axios": "^1.5.0",
|
||||||
"date-fns": "^2.30.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": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.14.2",
|
"react-router-dom": "^6.14.2",
|
||||||
@@ -7664,6 +7667,21 @@
|
|||||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom7": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom7/-/dom7-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ssr-window": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dom7/node_modules/ssr-window": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/domelementtype": {
|
"node_modules/domelementtype": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
@@ -9448,6 +9466,49 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framework7": {
|
||||||
|
"version": "9.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/framework7/-/framework7-9.1.2.tgz",
|
||||||
|
"integrity": "sha512-XXvIeiRri3imFiz4wZZjBgr0b7mqVosFX1rRABM86iUyaWqj1GQEuQsKzaGXFw1nzEuiX7+r39vtF7Fq5bUiJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dom7": "^4.0.6",
|
||||||
|
"htm": "^3.1.1",
|
||||||
|
"path-to-regexp": "^6.3.0",
|
||||||
|
"skeleton-elements": "^4.0.1",
|
||||||
|
"ssr-window": "^5.0.1",
|
||||||
|
"swiper": "^12.1.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/framework7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/framework7-icons": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/framework7-icons/-/framework7-icons-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-bvHMLyujV9TFuudehd3ORZ/EvNp19Ir3ckVzYAOf3MkLymHba/9oHLsgopCh0x5UsrYZUpkrE+fd7ggj5y4wRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/framework7-react": {
|
||||||
|
"version": "9.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/framework7-react/-/framework7-react-9.1.2.tgz",
|
||||||
|
"integrity": "sha512-uKLA29qObVZfG1OPtnLUdkxtnkqlqVJAh6GybIfE0WWGgLgAYb9X43WCeTGznRqDjfgdPVHlVesBDDBrfZqs3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/framework7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/framework7/node_modules/path-to-regexp": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
@@ -10012,6 +10073,12 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htm": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/html-encoding-sniffer": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
|
||||||
@@ -16647,6 +16714,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/skeleton-elements": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/skeleton-elements/-/skeleton-elements-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-T7YSF/Vu/raUcM6v3HiE4VSY/OvrNflg8Dur3Zza6VVJkq4slxm4pJRpGLNhoOfblIPZLQKh1cu7ADKveyqm/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/slash": {
|
"node_modules/slash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||||
@@ -16844,6 +16917,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/ssr-window": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-WVXlhQsm54HC+FnJfEbccEgNF7mKXtnFUB8Xn7rx2dsWHOlBdqezdX88Vjh6pVGaa0ZvL+PoSu7rEcBuNmxt6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stable": {
|
"node_modules/stable": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
|
||||||
@@ -17449,6 +17528,25 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swiper": {
|
||||||
|
"version": "12.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.2.0.tgz",
|
||||||
|
"integrity": "sha512-K8uXsBZU6ME97Ia3xbBge8IRCnR1lOmIILzvY/jGVic7dSTQ530s3uO8RvXbPUtkkXLWIwmZLRPbtDxRWVAFdg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "custom",
|
||||||
|
"url": "https://sponsors.nolimits4web.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nolimits4web"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/symbol-tree": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user