Files
GarminHealthLab/client/src/components/charts/MetricCard.tsx
ericwyuan 7ab150537d [阶段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>
2026-08-23 23:38:21 +08:00

87 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;