[阶段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;
|
||||
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);
|
||||
padding: 0.85rem 1rem;
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -20,7 +36,9 @@
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
@@ -32,7 +50,7 @@
|
||||
|
||||
.tile-meter {
|
||||
height: 4px;
|
||||
background: var(--surface-0);
|
||||
background: var(--ring-track);
|
||||
border-radius: 999px;
|
||||
margin-top: 0.55rem;
|
||||
overflow: hidden;
|
||||
@@ -42,6 +60,7 @@
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 1s var(--ease);
|
||||
}
|
||||
|
||||
.tile-detail {
|
||||
@@ -66,10 +85,29 @@
|
||||
|
||||
.tile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
|
||||
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) {
|
||||
.tile-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -78,3 +116,16 @@
|
||||
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 { useCountUp } from '../../lib/motion';
|
||||
import Sparkline from './Sparkline';
|
||||
import './StatTile.css';
|
||||
|
||||
export type Status = 'good' | 'warning' | 'serious' | 'critical';
|
||||
|
||||
/* Status is carried by an icon plus a label, never by colour alone — the
|
||||
light-surface status steps are deliberately below 3:1. */
|
||||
light-surface status steps sit below 3:1 by design. */
|
||||
const STATUS_ICON: Record<Status, string> = {
|
||||
good: '●',
|
||||
warning: '▲',
|
||||
@@ -22,21 +24,36 @@ interface StatTileProps {
|
||||
statusLabel?: string;
|
||||
/** 0–1; draws a goal meter under the value. */
|
||||
progress?: number | null;
|
||||
/** Recent history for an inline trend. */
|
||||
trend?: Array<number | null | undefined>;
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
label, value, unit, detail, status, statusLabel, progress,
|
||||
label, value, unit, detail, status, statusLabel, progress, trend, decimals = 0,
|
||||
}: StatTileProps) {
|
||||
const numeric = typeof value === 'number' ? value : null;
|
||||
const animated = useCountUp(numeric);
|
||||
|
||||
const display =
|
||||
value == null
|
||||
? '—'
|
||||
: typeof value === 'number'
|
||||
? value.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
: numeric != null
|
||||
? (animated ?? numeric).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
: value;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{display}
|
||||
{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,
|
||||
} from '../services/api';
|
||||
import StatTile from '../components/charts/StatTile';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Pages.css';
|
||||
|
||||
type Tab = 'badges' | 'records' | 'activities';
|
||||
@@ -53,7 +54,14 @@ function Achievements() {
|
||||
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.
|
||||
const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Daily.css';
|
||||
import './Pages.css';
|
||||
|
||||
@@ -198,7 +199,7 @@ function Daily() {
|
||||
</header>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{loading && <div className="page-loading">加载中…</div>}
|
||||
{loading && <Skeleton count={8} variant="row" />}
|
||||
|
||||
{!loading && !error && !day && (
|
||||
<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 Chart from '../components/charts/Chart';
|
||||
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';
|
||||
|
||||
const DAYS = 30;
|
||||
@@ -32,6 +36,59 @@ function rhrStatus(bpm: number | null): [Status, string] | [] {
|
||||
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() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -57,7 +114,15 @@ function Dashboard() {
|
||||
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) {
|
||||
return (
|
||||
@@ -104,6 +169,8 @@ function Dashboard() {
|
||||
<Link to="/trends" className="btn btn-plain">查看全部趋势 →</Link>
|
||||
</header>
|
||||
|
||||
<StepHero today={today} history={days} />
|
||||
|
||||
{/* Activity ---------------------------------------------------------- */}
|
||||
<section className="section">
|
||||
<h3 className="section-title">活动</h3>
|
||||
@@ -111,6 +178,7 @@ function Dashboard() {
|
||||
<StatTile
|
||||
label="步数"
|
||||
value={today.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
|
||||
@@ -118,18 +186,22 @@ function Dashboard() {
|
||||
/>
|
||||
<StatTile
|
||||
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"
|
||||
decimals={2}
|
||||
/>
|
||||
<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={
|
||||
@@ -140,10 +212,10 @@ function Dashboard() {
|
||||
/>
|
||||
<StatTile
|
||||
label="久坐"
|
||||
value={round(
|
||||
today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null, 1
|
||||
)}
|
||||
trend={days.map((d) => d.sedentarySeconds)}
|
||||
value={today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null}
|
||||
unit="小时"
|
||||
decimals={1}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -154,6 +226,7 @@ function Dashboard() {
|
||||
<div className="tile-grid">
|
||||
<StatTile
|
||||
label="静息心率"
|
||||
trend={days.map((d) => d.heartRate)}
|
||||
value={today.heartRate}
|
||||
unit="bpm"
|
||||
status={rhrTone}
|
||||
@@ -171,12 +244,15 @@ function Dashboard() {
|
||||
/>
|
||||
<StatTile
|
||||
label="心率变异性"
|
||||
decimals={1}
|
||||
trend={days.map((d) => d.heartRateVariability)}
|
||||
value={round(today.heartRateVariability)}
|
||||
unit="ms"
|
||||
detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label="平均压力"
|
||||
trend={days.map((d) => d.stress)}
|
||||
value={today.stress}
|
||||
detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
|
||||
/>
|
||||
@@ -203,8 +279,10 @@ function Dashboard() {
|
||||
<div className="tile-grid">
|
||||
<StatTile
|
||||
label="睡眠时长"
|
||||
trend={days.map((d) => d.sleepDuration)}
|
||||
value={today.sleepDuration}
|
||||
unit="小时"
|
||||
decimals={1}
|
||||
status={sleepTone}
|
||||
statusLabel={sleepWord}
|
||||
detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
|
||||
@@ -212,12 +290,16 @@ function Dashboard() {
|
||||
<StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" />
|
||||
<StatTile
|
||||
label="血氧"
|
||||
decimals={1}
|
||||
trend={days.map((d) => d.spo2Avg)}
|
||||
value={round(today.spo2Avg)}
|
||||
unit="%"
|
||||
detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label="呼吸频率"
|
||||
decimals={1}
|
||||
trend={days.map((d) => d.respirationAvg)}
|
||||
value={round(today.respirationAvg)}
|
||||
unit="次/分"
|
||||
detail={
|
||||
|
||||
@@ -455,3 +455,79 @@
|
||||
color: var(--text-muted);
|
||||
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 Chart from '../components/charts/Chart';
|
||||
import StatTile from '../components/charts/StatTile';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Pages.css';
|
||||
|
||||
const RANGES = [7, 14, 30, 90];
|
||||
@@ -95,7 +96,13 @@ function Sleep() {
|
||||
</header>
|
||||
|
||||
{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 && (
|
||||
<div className="empty-state">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -296,7 +297,7 @@ function Trends() {
|
||||
</section>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{loading && <div className="page-loading">加载中…</div>}
|
||||
{loading && <Skeleton count={6} variant="chart" />}
|
||||
|
||||
{!loading && !error && visible.length === 0 && (
|
||||
<p className="placeholder">所有指标都已隐藏,点上面的标签重新显示。</p>
|
||||
|
||||
@@ -47,8 +47,16 @@
|
||||
--accent-solid: #256abf;
|
||||
--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;
|
||||
--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
|
||||
@@ -57,14 +65,17 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:where(:not([data-theme='light'])) {
|
||||
color-scheme: dark;
|
||||
--surface-0: #131312;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--border: #34342f;
|
||||
--border-strong: #45443e;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #8f8e85;
|
||||
/* A cool near-black rather than a warm one: the blue-black surface is
|
||||
what reads as a fitness app, and the series steps were re-validated
|
||||
against it (all four still clear 3:1 and every CVD gate). */
|
||||
--surface-0: #0f1115;
|
||||
--surface-1: #181b21;
|
||||
--surface-2: #1f232b;
|
||||
--border: #262a33;
|
||||
--border-strong: #363b47;
|
||||
--text-primary: #e9ecf1;
|
||||
--text-secondary: #a9b1bd;
|
||||
--text-muted: #78818f;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
@@ -73,25 +84,32 @@
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
|
||||
--grid: #2c2c28;
|
||||
--grid: #22262e;
|
||||
--accent: #3987e5;
|
||||
--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-lift: 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--surface-0: #131312;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--border: #34342f;
|
||||
--border-strong: #45443e;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #8f8e85;
|
||||
/* A cool near-black rather than a warm one: the blue-black surface is
|
||||
what reads as a fitness app, and the series steps were re-validated
|
||||
against it (all four still clear 3:1 and every CVD gate). */
|
||||
--surface-0: #0f1115;
|
||||
--surface-1: #181b21;
|
||||
--surface-2: #1f232b;
|
||||
--border: #262a33;
|
||||
--border-strong: #363b47;
|
||||
--text-primary: #e9ecf1;
|
||||
--text-secondary: #a9b1bd;
|
||||
--text-muted: #78818f;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
@@ -100,10 +118,14 @@
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
|
||||
--grid: #2c2c28;
|
||||
--grid: #22262e;
|
||||
--accent: #3987e5;
|
||||
--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-lift: 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user