[阶段9] 界面打磨:hero 圆环、sparkline、骨架屏、动效
参考了 itsdrchen/Garmin-AI-Coach 与主流健康 App(Apple 活动圆环、 Oura/Whoop 的评分环、Garmin Connect 的卡内趋势)的做法。 hero 区(每个视图只有一个 hero 数字): - 步数目标圆环,超额时转为 status-good 色并保持满环 + 端点标记, 而不是绕第二圈 —— 绕圈会让 101% 看起来比 100% 还少 - 圆环轨道用填充色同色系的浅阶而非中性灰,两者才读作同一个量器 sparkline(12 点,卡片内嵌): - 线用去强调色,只有最新一点用强调色,让它成为数字的背景而非对手 - null 处断开而不插值 —— 设备没记录的那天连一条平线过去等于编数据 骨架屏取代转圈: - 形状与将要出现的内容一致,数据到位时布局不跳 - 用扫光而非闪烁,读起来像进度而不是元素在求关注 动效: - 卡片/图表依次入场(45ms 递增),引导视线扫过而不是整屏同时砸下来 - 数字滚动到位、圆环扫过、sparkline 描线、悬停抬升、按下微缩 - 全部尊重 prefers-reduced-motion:直接跳过而非缩短时长 —— 引起不适的是位移本身,不是它持续多久 深色改为冷调(借鉴参考项目): - 表面从暖近黑 #1a1a19 改为冷近黑 #181b21,更像运动 App - 换表面意味着原有校验作废,已用校验器对新表面重跑:四个系列色 全部通过含 3:1 对比度;文字亦逐个复核(正文 14.56:1、 次要 7.98、弱化 4.38、按钮白字 6.63) fix(a11y): 大号独立数字不应使用 tabular-nums - 等宽数字让每个字形都占 0 的宽度,大字号下显得松散; 等宽只保留给需要纵向对齐的表格列与坐标轴刻度 fix: 数字滚动动画吞掉了小数位 - StatTile 内部按 decimals 格式化,而调用方已经先 round 过一次, 距离显示成 "9 km"、久坐成 "11 小时"。改为传原值 + decimals Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
66
client/src/components/Skeleton.css
Normal file
66
client/src/components/Skeleton.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
.skeleton-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tile {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-chart {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tile .skeleton { height: 92px; }
|
||||||
|
.skeleton-chart .skeleton { height: 268px; }
|
||||||
|
.skeleton-row .skeleton { height: 44px; }
|
||||||
|
|
||||||
|
/* A sheen travelling across the block, not a pulse: it reads as loading
|
||||||
|
progress rather than as a element blinking for attention. */
|
||||||
|
.skeleton::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
color-mix(in srgb, var(--text-primary) 6%, transparent),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: sheen 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sheen {
|
||||||
|
to { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton::after { animation: none; }
|
||||||
|
}
|
||||||
26
client/src/components/Skeleton.tsx
Normal file
26
client/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import './Skeleton.css';
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
/** How many placeholder blocks to draw. */
|
||||||
|
count?: number;
|
||||||
|
variant?: 'tile' | 'chart' | 'row';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder shaped like the content that is coming.
|
||||||
|
*
|
||||||
|
* A spinner says "wait"; a skeleton says "here is what is arriving and where",
|
||||||
|
* so the layout does not jump when the data lands.
|
||||||
|
*/
|
||||||
|
function Skeleton({ count = 4, variant = 'tile' }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div className={`skeleton-group skeleton-${variant}`} aria-hidden="true">
|
||||||
|
{Array.from({ length: count }, (_, i) => (
|
||||||
|
<div className="skeleton" key={i} style={{ animationDelay: `${i * 90}ms` }} />
|
||||||
|
))}
|
||||||
|
<span className="sr-only" role="status">加载中</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Skeleton;
|
||||||
@@ -169,3 +169,36 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viz {
|
||||||
|
transition: box-shadow 0.2s var(--ease), border-color 0.2s var(--ease);
|
||||||
|
animation: viz-in 0.45s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viz:hover {
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes viz-in {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-grid > .viz:nth-child(2) { animation-delay: 60ms; }
|
||||||
|
.chart-grid > .viz:nth-child(3) { animation-delay: 120ms; }
|
||||||
|
.chart-grid > .viz:nth-child(4) { animation-delay: 180ms; }
|
||||||
|
.chart-grid > .viz:nth-child(n + 5) { animation-delay: 220ms; }
|
||||||
|
|
||||||
|
.viz-toggle {
|
||||||
|
transition: border-color 0.15s var(--ease), color 0.15s var(--ease),
|
||||||
|
background 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viz-toggle:hover {
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.viz { animation: none; transition: none; }
|
||||||
|
}
|
||||||
|
|||||||
25
client/src/components/charts/Ring.css
Normal file
25
client/src/components/charts/Ring.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
.ring {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-fill {
|
||||||
|
transition: stroke-dashoffset 1.1s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
stroke 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-center {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
71
client/src/components/charts/Ring.tsx
Normal file
71
client/src/components/charts/Ring.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { usePrefersReducedMotion } from '../../lib/motion';
|
||||||
|
import './Ring.css';
|
||||||
|
|
||||||
|
interface RingProps {
|
||||||
|
/** 0–1; values above 1 are drawn as a full ring plus an overshoot mark. */
|
||||||
|
progress: number | null;
|
||||||
|
size?: number;
|
||||||
|
thickness?: number;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A goal ring.
|
||||||
|
*
|
||||||
|
* The track is a lighter step of the fill's own ramp rather than plain grey,
|
||||||
|
* so the pair reads as one meter at a glance. Progress past 100% keeps the
|
||||||
|
* ring full and adds a small cap mark instead of wrapping a second lap, which
|
||||||
|
* would read as a much smaller number than it is.
|
||||||
|
*/
|
||||||
|
function Ring({ progress, size = 132, thickness = 11, children, label }: RingProps) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const [shown, setShown] = useState(reduced ? progress ?? 0 : 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (progress == null) return;
|
||||||
|
if (reduced) { setShown(progress); return; }
|
||||||
|
// One frame's delay so the browser paints the empty ring first and the
|
||||||
|
// transition actually runs.
|
||||||
|
const id = requestAnimationFrame(() => setShown(progress));
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [progress, reduced]);
|
||||||
|
|
||||||
|
const radius = (size - thickness) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const clamped = Math.min(1, Math.max(0, shown));
|
||||||
|
const offset = circumference * (1 - clamped);
|
||||||
|
const over = (progress ?? 0) > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ring" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} role="img" aria-label={label}>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--ring-track)"
|
||||||
|
strokeWidth={thickness}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className={reduced ? undefined : 'ring-fill'}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={over ? 'var(--status-good)' : 'var(--accent)'}
|
||||||
|
strokeWidth={thickness}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="ring-center">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Ring;
|
||||||
22
client/src/components/charts/Sparkline.css
Normal file
22
client/src/components/charts/Sparkline.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.spark {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-empty {
|
||||||
|
display: inline-block;
|
||||||
|
width: 68px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Draw the line on rather than fading it in — the stroke reveal traces the
|
||||||
|
shape the reader is about to interpret. */
|
||||||
|
.spark-path {
|
||||||
|
stroke-dasharray: 240;
|
||||||
|
stroke-dashoffset: 240;
|
||||||
|
animation: spark-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spark-draw {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
89
client/src/components/charts/Sparkline.tsx
Normal file
89
client/src/components/charts/Sparkline.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { usePrefersReducedMotion } from '../../lib/motion';
|
||||||
|
import './Sparkline.css';
|
||||||
|
|
||||||
|
interface SparklineProps {
|
||||||
|
values: Array<number | null | undefined>;
|
||||||
|
/** Points to keep, newest last. */
|
||||||
|
points?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny trend beside a stat.
|
||||||
|
*
|
||||||
|
* The line sits in a de-emphasised ink and only the latest point wears the
|
||||||
|
* accent, so the sparkline reads as context for the number it accompanies
|
||||||
|
* rather than competing with it. It is decorative-by-position but not
|
||||||
|
* decorative-by-meaning, so it carries an accessible label and is hidden from
|
||||||
|
* the tree only when one is not supplied.
|
||||||
|
*/
|
||||||
|
function Sparkline({ values, points = 12, width = 68, height = 22, label }: SparklineProps) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
|
||||||
|
const geometry = useMemo(() => {
|
||||||
|
const recent = values.slice(-points);
|
||||||
|
const present = recent.filter((v): v is number => v != null);
|
||||||
|
if (present.length < 2) return null;
|
||||||
|
|
||||||
|
const min = Math.min(...present);
|
||||||
|
const max = Math.max(...present);
|
||||||
|
const span = max - min || 1;
|
||||||
|
const step = width / Math.max(recent.length - 1, 1);
|
||||||
|
|
||||||
|
const coords: Array<[number, number] | null> = recent.map((v, i) =>
|
||||||
|
v == null ? null : [i * step, height - ((v - min) / span) * (height - 4) - 2]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nulls break the path rather than being interpolated: a flat segment
|
||||||
|
// across a day the device recorded nothing would be an invented value.
|
||||||
|
let d = '';
|
||||||
|
let pen = false;
|
||||||
|
for (const c of coords) {
|
||||||
|
if (!c) { pen = false; continue; }
|
||||||
|
d += `${pen ? 'L' : 'M'}${c[0].toFixed(1)},${c[1].toFixed(1)} `;
|
||||||
|
pen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = [...coords].reverse().find((c): c is [number, number] => c != null);
|
||||||
|
return { d: d.trim(), last, length: recent.length };
|
||||||
|
}, [values, points, width, height]);
|
||||||
|
|
||||||
|
if (!geometry) return <span className="spark-empty" aria-hidden="true" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="spark"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
role={label ? 'img' : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
aria-hidden={label ? undefined : true}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className={reduced ? undefined : 'spark-path'}
|
||||||
|
d={geometry.d}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--spark-ink)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{geometry.last && (
|
||||||
|
<circle
|
||||||
|
cx={geometry.last[0]}
|
||||||
|
cy={geometry.last[1]}
|
||||||
|
r={2.5}
|
||||||
|
fill="var(--accent)"
|
||||||
|
stroke="var(--surface-1)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sparkline;
|
||||||
@@ -4,12 +4,28 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease),
|
||||||
|
border-color 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
min-height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-label {
|
.tile-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -20,7 +36,9 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
font-variant-numeric: tabular-nums;
|
/* Proportional figures on purpose: tabular-nums gives every digit the width
|
||||||
|
of a zero, which makes a large standalone number look loose. Tabular is
|
||||||
|
reserved for columns that must align (tables, axis ticks). */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-unit {
|
.tile-unit {
|
||||||
@@ -32,7 +50,7 @@
|
|||||||
|
|
||||||
.tile-meter {
|
.tile-meter {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: var(--surface-0);
|
background: var(--ring-track);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-top: 0.55rem;
|
margin-top: 0.55rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -42,6 +60,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
transition: width 1s var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-detail {
|
.tile-detail {
|
||||||
@@ -66,10 +85,29 @@
|
|||||||
|
|
||||||
.tile-grid {
|
.tile-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cards arrive in sequence rather than all at once, so the eye is led across
|
||||||
|
the group instead of being hit by the whole grid at the same instant. */
|
||||||
|
.tile-grid > * {
|
||||||
|
animation: tile-in 0.42s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-grid > *:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.tile-grid > *:nth-child(2) { animation-delay: 45ms; }
|
||||||
|
.tile-grid > *:nth-child(3) { animation-delay: 90ms; }
|
||||||
|
.tile-grid > *:nth-child(4) { animation-delay: 135ms; }
|
||||||
|
.tile-grid > *:nth-child(5) { animation-delay: 180ms; }
|
||||||
|
.tile-grid > *:nth-child(6) { animation-delay: 225ms; }
|
||||||
|
.tile-grid > *:nth-child(n + 7) { animation-delay: 270ms; }
|
||||||
|
|
||||||
|
@keyframes tile-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.tile-grid {
|
.tile-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
@@ -78,3 +116,16 @@
|
|||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tile,
|
||||||
|
.tile-meter-fill {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.tile:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.tile-grid > * {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { useCountUp } from '../../lib/motion';
|
||||||
|
import Sparkline from './Sparkline';
|
||||||
import './StatTile.css';
|
import './StatTile.css';
|
||||||
|
|
||||||
export type Status = 'good' | 'warning' | 'serious' | 'critical';
|
export type Status = 'good' | 'warning' | 'serious' | 'critical';
|
||||||
|
|
||||||
/* Status is carried by an icon plus a label, never by colour alone — the
|
/* Status is carried by an icon plus a label, never by colour alone — the
|
||||||
light-surface status steps are deliberately below 3:1. */
|
light-surface status steps sit below 3:1 by design. */
|
||||||
const STATUS_ICON: Record<Status, string> = {
|
const STATUS_ICON: Record<Status, string> = {
|
||||||
good: '●',
|
good: '●',
|
||||||
warning: '▲',
|
warning: '▲',
|
||||||
@@ -22,21 +24,36 @@ interface StatTileProps {
|
|||||||
statusLabel?: string;
|
statusLabel?: string;
|
||||||
/** 0–1; draws a goal meter under the value. */
|
/** 0–1; draws a goal meter under the value. */
|
||||||
progress?: number | null;
|
progress?: number | null;
|
||||||
|
/** Recent history for an inline trend. */
|
||||||
|
trend?: Array<number | null | undefined>;
|
||||||
|
decimals?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatTile({
|
function StatTile({
|
||||||
label, value, unit, detail, status, statusLabel, progress,
|
label, value, unit, detail, status, statusLabel, progress, trend, decimals = 0,
|
||||||
}: StatTileProps) {
|
}: StatTileProps) {
|
||||||
|
const numeric = typeof value === 'number' ? value : null;
|
||||||
|
const animated = useCountUp(numeric);
|
||||||
|
|
||||||
const display =
|
const display =
|
||||||
value == null
|
value == null
|
||||||
? '—'
|
? '—'
|
||||||
: typeof value === 'number'
|
: numeric != null
|
||||||
? value.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
? (animated ?? numeric).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
})
|
||||||
: value;
|
: value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tile">
|
<div className="tile">
|
||||||
<div className="tile-label">{label}</div>
|
<div className="tile-head">
|
||||||
|
<span className="tile-label">{label}</span>
|
||||||
|
{trend && trend.some((v) => v != null) && (
|
||||||
|
<Sparkline values={trend} label={`${label}近期走势`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="tile-value">
|
<div className="tile-value">
|
||||||
{display}
|
{display}
|
||||||
{unit && value != null && <span className="tile-unit">{unit}</span>}
|
{unit && value != null && <span className="tile-unit">{unit}</span>}
|
||||||
|
|||||||
100
client/src/lib/motion.ts
Normal file
100
client/src/lib/motion.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the viewer asked for reduced motion.
|
||||||
|
*
|
||||||
|
* Every animation in the app checks this. Vestibular disorders make sweeping
|
||||||
|
* movement genuinely unpleasant, and the setting is the user telling us so —
|
||||||
|
* so motion is skipped outright rather than merely shortened.
|
||||||
|
*/
|
||||||
|
export function usePrefersReducedMotion(): boolean {
|
||||||
|
const query = '(prefers-reduced-motion: reduce)';
|
||||||
|
const [reduced, setReduced] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.matchMedia(query).matches
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia(query);
|
||||||
|
const onChange = () => setReduced(mq.matches);
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ease-out cubic: fast start, gentle settle — reads as responsive. */
|
||||||
|
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate a number from 0 to `value`.
|
||||||
|
*
|
||||||
|
* Returns the target immediately when motion is reduced, or when the value is
|
||||||
|
* not a number — a counter that ticks up to "—" would be nonsense.
|
||||||
|
*/
|
||||||
|
export function useCountUp(value: number | null, durationMs = 650): number | null {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const [display, setDisplay] = useState<number | null>(value);
|
||||||
|
const frame = useRef<number>();
|
||||||
|
const from = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value == null || reduced) {
|
||||||
|
setDisplay(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const origin = from.current;
|
||||||
|
const delta = value - origin;
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - start) / durationMs);
|
||||||
|
setDisplay(origin + delta * easeOut(t));
|
||||||
|
if (t < 1) frame.current = requestAnimationFrame(tick);
|
||||||
|
else from.current = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.current = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (frame.current) cancelAnimationFrame(frame.current);
|
||||||
|
};
|
||||||
|
}, [value, durationMs, reduced]);
|
||||||
|
|
||||||
|
return display;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal an element once it scrolls into view.
|
||||||
|
*
|
||||||
|
* Cards below the fold animating on page load is motion the viewer never sees;
|
||||||
|
* tying it to visibility means the movement always accompanies the reveal.
|
||||||
|
*/
|
||||||
|
export function useReveal<T extends HTMLElement>() {
|
||||||
|
const ref = useRef<T | null>(null);
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced) {
|
||||||
|
setShown(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setShown(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '0px 0px -40px 0px' }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [reduced]);
|
||||||
|
|
||||||
|
return { ref, shown };
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
apiClient, Activity, Badge, errorMessage, PersonalRecord,
|
apiClient, Activity, Badge, errorMessage, PersonalRecord,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import StatTile from '../components/charts/StatTile';
|
import StatTile from '../components/charts/StatTile';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
type Tab = 'badges' | 'records' | 'activities';
|
type Tab = 'badges' | 'records' | 'activities';
|
||||||
@@ -53,7 +54,14 @@ function Achievements() {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <div className="page-loading">加载中…</div>;
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>成就</h2>
|
||||||
|
<Skeleton count={4} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Badges cluster heavily by year, which is the only grouping that reads.
|
// Badges cluster heavily by year, which is the only grouping that reads.
|
||||||
const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => {
|
const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
|
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
import './Daily.css';
|
import './Daily.css';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ function Daily() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
{loading && <div className="page-loading">加载中…</div>}
|
{loading && <Skeleton count={8} variant="row" />}
|
||||||
|
|
||||||
{!loading && !error && !day && (
|
{!loading && !error && !day && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
|
|||||||
104
client/src/pages/Dashboard.css
Normal file
104
client/src/pages/Dashboard.css
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/* Hero -------------------------------------------------------------------- */
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.75rem;
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
animation: hero-in 0.5s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-in {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one hero figure on the view. Proportional figures, and the app's own
|
||||||
|
sans — a display face here would read as decoration rather than data. */
|
||||||
|
.hero-value {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 660;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-caption {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-facts {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-headline {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-list dt {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-list dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 620;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-skeleton {
|
||||||
|
height: 164px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts ------------------------------------------------------------------ */
|
||||||
|
.charts-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
.hero-list {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.charts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero { animation: none; }
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ 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 StatTile, { Status } from '../components/charts/StatTile';
|
||||||
|
import Ring from '../components/charts/Ring';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import { useCountUp } from '../lib/motion';
|
||||||
|
import './Dashboard.css';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
const DAYS = 30;
|
const DAYS = 30;
|
||||||
@@ -32,6 +36,59 @@ function rhrStatus(bpm: number | null): [Status, string] | [] {
|
|||||||
return ['good', '正常'];
|
return ['good', '正常'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The one hero figure on this view: today's steps against the day's goal. */
|
||||||
|
function StepHero({ today, history }: { today: HealthDay; history: HealthDay[] }) {
|
||||||
|
const goal = today.stepGoal ?? null;
|
||||||
|
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 Dashboard() {
|
function Dashboard() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -57,7 +114,15 @@ function Dashboard() {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <div className="page-loading">加载中…</div>;
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>今日概览</h2>
|
||||||
|
<div className="hero-skeleton" aria-hidden="true" />
|
||||||
|
<Skeleton count={6} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -104,6 +169,8 @@ function Dashboard() {
|
|||||||
<Link to="/trends" className="btn btn-plain">查看全部趋势 →</Link>
|
<Link to="/trends" className="btn btn-plain">查看全部趋势 →</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<StepHero today={today} history={days} />
|
||||||
|
|
||||||
{/* Activity ---------------------------------------------------------- */}
|
{/* Activity ---------------------------------------------------------- */}
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<h3 className="section-title">活动</h3>
|
<h3 className="section-title">活动</h3>
|
||||||
@@ -111,6 +178,7 @@ function Dashboard() {
|
|||||||
<StatTile
|
<StatTile
|
||||||
label="步数"
|
label="步数"
|
||||||
value={today.steps}
|
value={today.steps}
|
||||||
|
trend={days.map((d) => d.steps)}
|
||||||
detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined}
|
detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined}
|
||||||
progress={
|
progress={
|
||||||
today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null
|
today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null
|
||||||
@@ -118,18 +186,22 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="距离"
|
label="距离"
|
||||||
value={round(today.distanceMeters != null ? today.distanceMeters / 1000 : null, 2)}
|
trend={days.map((d) => d.distanceMeters)}
|
||||||
|
value={today.distanceMeters != null ? today.distanceMeters / 1000 : null}
|
||||||
unit="km"
|
unit="km"
|
||||||
|
decimals={2}
|
||||||
/>
|
/>
|
||||||
<StatTile label="爬楼" value={round(today.floorsAscended)} unit="层" />
|
<StatTile label="爬楼" value={round(today.floorsAscended)} unit="层" />
|
||||||
<StatTile
|
<StatTile
|
||||||
label="强度分钟"
|
label="强度分钟"
|
||||||
|
trend={days.map((d) => d.intensityMinutes)}
|
||||||
value={today.intensityMinutes}
|
value={today.intensityMinutes}
|
||||||
unit="分钟"
|
unit="分钟"
|
||||||
detail="中等以上强度"
|
detail="中等以上强度"
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="总消耗"
|
label="总消耗"
|
||||||
|
trend={days.map((d) => d.caloriesBurned)}
|
||||||
value={round(today.caloriesBurned)}
|
value={round(today.caloriesBurned)}
|
||||||
unit="kcal"
|
unit="kcal"
|
||||||
detail={
|
detail={
|
||||||
@@ -140,10 +212,10 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="久坐"
|
label="久坐"
|
||||||
value={round(
|
trend={days.map((d) => d.sedentarySeconds)}
|
||||||
today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null, 1
|
value={today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null}
|
||||||
)}
|
|
||||||
unit="小时"
|
unit="小时"
|
||||||
|
decimals={1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -154,6 +226,7 @@ function Dashboard() {
|
|||||||
<div className="tile-grid">
|
<div className="tile-grid">
|
||||||
<StatTile
|
<StatTile
|
||||||
label="静息心率"
|
label="静息心率"
|
||||||
|
trend={days.map((d) => d.heartRate)}
|
||||||
value={today.heartRate}
|
value={today.heartRate}
|
||||||
unit="bpm"
|
unit="bpm"
|
||||||
status={rhrTone}
|
status={rhrTone}
|
||||||
@@ -171,12 +244,15 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="心率变异性"
|
label="心率变异性"
|
||||||
|
decimals={1}
|
||||||
|
trend={days.map((d) => d.heartRateVariability)}
|
||||||
value={round(today.heartRateVariability)}
|
value={round(today.heartRateVariability)}
|
||||||
unit="ms"
|
unit="ms"
|
||||||
detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
|
detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="平均压力"
|
label="平均压力"
|
||||||
|
trend={days.map((d) => d.stress)}
|
||||||
value={today.stress}
|
value={today.stress}
|
||||||
detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
|
detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -203,8 +279,10 @@ function Dashboard() {
|
|||||||
<div className="tile-grid">
|
<div className="tile-grid">
|
||||||
<StatTile
|
<StatTile
|
||||||
label="睡眠时长"
|
label="睡眠时长"
|
||||||
|
trend={days.map((d) => d.sleepDuration)}
|
||||||
value={today.sleepDuration}
|
value={today.sleepDuration}
|
||||||
unit="小时"
|
unit="小时"
|
||||||
|
decimals={1}
|
||||||
status={sleepTone}
|
status={sleepTone}
|
||||||
statusLabel={sleepWord}
|
statusLabel={sleepWord}
|
||||||
detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
|
detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
|
||||||
@@ -212,12 +290,16 @@ function Dashboard() {
|
|||||||
<StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" />
|
<StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" />
|
||||||
<StatTile
|
<StatTile
|
||||||
label="血氧"
|
label="血氧"
|
||||||
|
decimals={1}
|
||||||
|
trend={days.map((d) => d.spo2Avg)}
|
||||||
value={round(today.spo2Avg)}
|
value={round(today.spo2Avg)}
|
||||||
unit="%"
|
unit="%"
|
||||||
detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
|
detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="呼吸频率"
|
label="呼吸频率"
|
||||||
|
decimals={1}
|
||||||
|
trend={days.map((d) => d.respirationAvg)}
|
||||||
value={round(today.respirationAvg)}
|
value={round(today.respirationAvg)}
|
||||||
unit="次/分"
|
unit="次/分"
|
||||||
detail={
|
detail={
|
||||||
|
|||||||
@@ -455,3 +455,79 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 2.4em;
|
min-width: 2.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Micro-interactions ------------------------------------------------------- */
|
||||||
|
.btn:active:not(:disabled),
|
||||||
|
.chip:active,
|
||||||
|
.range-tab:active,
|
||||||
|
.metric-tab:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn,
|
||||||
|
.chip,
|
||||||
|
.range-tab,
|
||||||
|
.metric-tab {
|
||||||
|
transition: transform 0.12s var(--ease), background 0.15s var(--ease),
|
||||||
|
border-color 0.15s var(--ease), color 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
transition: transform 0.18s var(--ease), box-shadow 0.18s var(--ease),
|
||||||
|
border-color 0.18s var(--ease);
|
||||||
|
animation: tile-in 0.35s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-grid > *:nth-child(2) { animation-delay: 30ms; }
|
||||||
|
.badge-grid > *:nth-child(3) { animation-delay: 60ms; }
|
||||||
|
.badge-grid > *:nth-child(4) { animation-delay: 90ms; }
|
||||||
|
.badge-grid > *:nth-child(n + 5) { animation-delay: 110ms; }
|
||||||
|
|
||||||
|
.data-table tbody tr {
|
||||||
|
transition: background 0.12s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
transition: background 0.12s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row:hover {
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
animation: page-in 0.3s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes page-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The viewer asked for less movement; skip it rather than merely shortening
|
||||||
|
it — sweeping motion is what causes the discomfort, not its duration. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
.btn:active:not(:disabled),
|
||||||
|
.chip:active,
|
||||||
|
.badge:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 from '../components/charts/StatTile';
|
import StatTile from '../components/charts/StatTile';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
const RANGES = [7, 14, 30, 90];
|
const RANGES = [7, 14, 30, 90];
|
||||||
@@ -95,7 +96,13 @@ function Sleep() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
{loading && <div className="page-loading">加载中…</div>}
|
{loading && (
|
||||||
|
<>
|
||||||
|
<Skeleton count={6} />
|
||||||
|
<div style={{ height: '1rem' }} />
|
||||||
|
<Skeleton count={2} variant="chart" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && !error && nights.length === 0 && (
|
{!loading && !error && nights.length === 0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||||
import Chart, { Series } from '../components/charts/Chart';
|
import Chart, { Series } from '../components/charts/Chart';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
import {
|
import {
|
||||||
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
||||||
} from '../lib/aggregate';
|
} from '../lib/aggregate';
|
||||||
@@ -296,7 +297,7 @@ function Trends() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
{loading && <div className="page-loading">加载中…</div>}
|
{loading && <Skeleton count={6} variant="chart" />}
|
||||||
|
|
||||||
{!loading && !error && visible.length === 0 && (
|
{!loading && !error && visible.length === 0 && (
|
||||||
<p className="placeholder">所有指标都已隐藏,点上面的标签重新显示。</p>
|
<p className="placeholder">所有指标都已隐藏,点上面的标签重新显示。</p>
|
||||||
|
|||||||
@@ -47,8 +47,16 @@
|
|||||||
--accent-solid: #256abf;
|
--accent-solid: #256abf;
|
||||||
--accent-soft: #eef4fd;
|
--accent-soft: #eef4fd;
|
||||||
|
|
||||||
|
/* Sparkline ink is de-emphasised so the number it accompanies stays the
|
||||||
|
figure; the ring track is a lighter step of the fill's own ramp so the
|
||||||
|
pair reads as one meter. */
|
||||||
|
--spark-ink: #9ec5f4;
|
||||||
|
--ring-track: #dfe9f7;
|
||||||
|
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 8px rgba(0, 0, 0, 0.03);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 8px rgba(0, 0, 0, 0.03);
|
||||||
|
--shadow-lift: 0 2px 4px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.07);
|
||||||
|
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark steps are the same hues re-stepped for the dark surface — selected and
|
/* Dark steps are the same hues re-stepped for the dark surface — selected and
|
||||||
@@ -57,14 +65,17 @@
|
|||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:where(:not([data-theme='light'])) {
|
:root:where(:not([data-theme='light'])) {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--surface-0: #131312;
|
/* A cool near-black rather than a warm one: the blue-black surface is
|
||||||
--surface-1: #1a1a19;
|
what reads as a fitness app, and the series steps were re-validated
|
||||||
--surface-2: #232322;
|
against it (all four still clear 3:1 and every CVD gate). */
|
||||||
--border: #34342f;
|
--surface-0: #0f1115;
|
||||||
--border-strong: #45443e;
|
--surface-1: #181b21;
|
||||||
--text-primary: #ffffff;
|
--surface-2: #1f232b;
|
||||||
--text-secondary: #c3c2b7;
|
--border: #262a33;
|
||||||
--text-muted: #8f8e85;
|
--border-strong: #363b47;
|
||||||
|
--text-primary: #e9ecf1;
|
||||||
|
--text-secondary: #a9b1bd;
|
||||||
|
--text-muted: #78818f;
|
||||||
|
|
||||||
--series-1: #3987e5;
|
--series-1: #3987e5;
|
||||||
--series-2: #d95926;
|
--series-2: #d95926;
|
||||||
@@ -73,25 +84,32 @@
|
|||||||
--series-5: #d55181;
|
--series-5: #d55181;
|
||||||
--series-6: #008300;
|
--series-6: #008300;
|
||||||
|
|
||||||
--grid: #2c2c28;
|
--grid: #22262e;
|
||||||
--accent: #3987e5;
|
--accent: #3987e5;
|
||||||
--accent-solid: #1c5cab;
|
--accent-solid: #1c5cab;
|
||||||
--accent-soft: #1d2938;
|
--accent-soft: #17233a;
|
||||||
|
|
||||||
|
--spark-ink: #4a6f9e;
|
||||||
|
--ring-track: #2c3a4d;
|
||||||
|
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lift: 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme='dark'] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--surface-0: #131312;
|
/* A cool near-black rather than a warm one: the blue-black surface is
|
||||||
--surface-1: #1a1a19;
|
what reads as a fitness app, and the series steps were re-validated
|
||||||
--surface-2: #232322;
|
against it (all four still clear 3:1 and every CVD gate). */
|
||||||
--border: #34342f;
|
--surface-0: #0f1115;
|
||||||
--border-strong: #45443e;
|
--surface-1: #181b21;
|
||||||
--text-primary: #ffffff;
|
--surface-2: #1f232b;
|
||||||
--text-secondary: #c3c2b7;
|
--border: #262a33;
|
||||||
--text-muted: #8f8e85;
|
--border-strong: #363b47;
|
||||||
|
--text-primary: #e9ecf1;
|
||||||
|
--text-secondary: #a9b1bd;
|
||||||
|
--text-muted: #78818f;
|
||||||
|
|
||||||
--series-1: #3987e5;
|
--series-1: #3987e5;
|
||||||
--series-2: #d95926;
|
--series-2: #d95926;
|
||||||
@@ -100,10 +118,14 @@
|
|||||||
--series-5: #d55181;
|
--series-5: #d55181;
|
||||||
--series-6: #008300;
|
--series-6: #008300;
|
||||||
|
|
||||||
--grid: #2c2c28;
|
--grid: #22262e;
|
||||||
--accent: #3987e5;
|
--accent: #3987e5;
|
||||||
--accent-solid: #1c5cab;
|
--accent-solid: #1c5cab;
|
||||||
--accent-soft: #1d2938;
|
--accent-soft: #17233a;
|
||||||
|
|
||||||
|
--spark-ink: #4a6f9e;
|
||||||
|
--ring-track: #2c3a4d;
|
||||||
|
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lift: 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user