[阶段7] 前端重做:31 项指标全部露出,配色经色盲校验

原来仪表板只有 4 张卡片和 4 张图,31 项指标里绝大多数没有出口。

信息架构重组为 7 个页面(原 5 个):
- 今日   按活动 / 心率压力 / 睡眠呼吸三组展示 16 项指标
- 趋势   15 组指标可切换,5 档时间窗口(最长一年)
- 睡眠   新增。分期堆叠图 + 夜间血氧/呼吸/压力
- 成就   新增。奖励徽章按年份分组、个人纪录、运动记录
- 建议 / 同步 / 设置 保持

配色(依据 dataviz 规范,用校验器实测而非目测):
- 采用验证过的分类色板并按既定 slot 顺序取色 —— 顺序本身就是
  色盲安全机制,不是审美选择,因此只取不循环
- 浅色 worst adjacent CVD ΔE 9.1 / 常视觉 22.9
  深色 worst adjacent CVD ΔE 8.4 / 常视觉 19.8,两档全部通过
- 浅色表面下 aqua 2.74:1、yellow 2.11:1 低于 3:1,按规范提供
  "relief":每张图都带表格视图,且图例始终与文字标签同现
- 深色不是自动反色,是同色相针对深色表面重新取阶并单独校验
- 状态色(good/warning/serious/critical)保留专用,绝不当作
  第 N 个系列色;且始终图标 + 文字同现,不靠颜色单独表意

图表规范:
- 单一 y 轴,绝不双轴;量纲不同的指标拆成不同图
- 2px 线宽、4px 圆角柱端锚定基线、堆叠段间 2px 表面色间隙、
  悬停标记 2px 表面色描边
- 两系列以上必有图例,单系列不加(标题已经点明)
- 折线 connectNulls,设备漏记的日子不把线打断
- 数值文字一律用文字色令牌,不染系列色

其他:
- 主题切换(自动/浅色/深色),选择写入 localStorage 并标在 <html>
- 导航改为顶部横向,移动端可横滑
- 表格、徽章、空状态等组件统一到设计令牌

验证方式: DOM 审计确认 2px 线宽、圆角为 A 4,4 弧、堆叠段
2px 间隙、图例数量与系列数匹配、两档主题令牌各自解析到校验过的色值。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 21:17:08 +08:00
parent cbbff61082
commit ad88ec7e41
20 changed files with 2131 additions and 767 deletions

View File

@@ -1,76 +0,0 @@
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
interface ChartProps {
title: string;
data: Array<Record<string, any>>;
type: 'line' | 'bar';
dataKey: string;
stroke?: string;
fill?: string;
height?: number;
}
function Chart({
title,
data,
type,
dataKey,
stroke = '#667eea',
fill = '#667eea',
height = 260,
}: ChartProps) {
// A row may carry some metrics and not others, so emptiness is decided per
// metric rather than by the length of `data`.
const hasValues = data.some((row) => row[dataKey] != null);
if (!hasValues) {
return (
<div className="chart-container">
<h4>{title}</h4>
<div className="chart-placeholder"></div>
</div>
);
}
const axisProps = { fontSize: 12, stroke: '#999' };
return (
<div className="chart-container">
<h4>{title}</h4>
<ResponsiveContainer width="100%" height={height}>
{type === 'line' ? (
<LineChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" {...axisProps} />
<YAxis {...axisProps} domain={['auto', 'auto']} />
<Tooltip />
{/* connectNulls keeps the line continuous across days the device
did not record, instead of breaking it into fragments. */}
<Line
type="monotone"
dataKey={dataKey}
stroke={stroke}
strokeWidth={2}
dot={{ r: 3 }}
connectNulls
name={title}
/>
</LineChart>
) : (
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" {...axisProps} />
<YAxis {...axisProps} />
<Tooltip />
<Bar dataKey={dataKey} fill={fill} radius={[3, 3, 0, 0]} name={title} />
</BarChart>
)}
</ResponsiveContainer>
</div>
);
}
export default Chart;

View File

@@ -1,129 +1,93 @@
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f5f5f5;
background: var(--surface-0);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: var(--surface-1);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 20;
}
.header-content {
max-width: 1400px;
.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: 1.8rem;
font-weight: bold;
margin-bottom: 0.5rem;
font-size: 0.95rem;
font-weight: 680;
color: var(--text-primary);
text-decoration: none;
white-space: nowrap;
}
.tagline {
font-size: 0.9rem;
opacity: 0.9;
}
.container {
.nav {
display: flex;
gap: 0.15rem;
flex: 1;
max-width: 1400px;
width: 100%;
margin: 0 auto;
gap: 2rem;
padding: 2rem;
overflow-x: auto;
scrollbar-width: none;
}
.sidebar {
width: 250px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
height: fit-content;
position: sticky;
top: 2rem;
}
.nav-list {
list-style: none;
padding: 0;
}
.nav::-webkit-scrollbar { display: none; }
.nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
padding: 0.4rem 0.7rem;
border-radius: 7px;
color: var(--text-secondary);
text-decoration: none;
color: #333;
border-left: 3px solid transparent;
transition: all 0.3s ease;
font-size: 0.88rem;
white-space: nowrap;
transition: background 0.15s ease, color 0.15s ease;
}
.nav-link:hover {
background-color: #f5f5f5;
border-left-color: #667eea;
background: var(--surface-0);
color: var(--text-primary);
}
.nav-link.active {
background-color: #f0f0ff;
border-left-color: #667eea;
color: #667eea;
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
}
.icon {
font-size: 1.2rem;
.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;
}
.label {
flex: 1;
.theme-toggle:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
.content {
flex: 1;
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-width: 1180px;
margin: 0 auto;
padding: 1.75rem 1.5rem 4rem;
}
.footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.9rem;
border-top: 1px solid #eee;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
padding: 1rem;
}
.sidebar {
width: 100%;
position: static;
}
.nav-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
}
.nav-link {
flex: 1;
min-width: 100px;
font-size: 0.9rem;
}
.logo {
font-size: 1.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; }
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import './Layout.css';
@@ -6,51 +6,81 @@ interface LayoutProps {
children: React.ReactNode;
}
const NAV = [
{ path: '/', label: '今日' },
{ path: '/trends', label: '趋势' },
{ path: '/sleep', label: '睡眠' },
{ path: '/achievements', label: '成就' },
{ 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 navigationItems = [
{ path: '/', label: '仪表板', icon: '📊' },
{ path: '/sync', label: '数据同步', icon: '🔄' },
{ path: '/analysis', label: '数据分析', icon: '📈' },
{ path: '/recommendations', label: '健康建议', icon: '💡' },
{ path: '/settings', label: '设置', icon: '⚙️' },
];
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-content">
<h1 className="logo">🏃 Garmin Health Lab</h1>
<p className="tagline"></p>
<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>
<div className="container">
<nav className="sidebar">
<ul className="nav-list">
{navigationItems.map(item => (
<li key={item.path}>
<Link
to={item.path}
className={`nav-link ${location.pathname === item.path ? 'active' : ''}`}
>
<span className="icon">{item.icon}</span>
<span className="label">{item.label}</span>
</Link>
</li>
))}
</ul>
</nav>
<main className="content">
{children}
</main>
</div>
<footer className="footer">
<p>© 2024 Garmin Health Lab. All rights reserved.</p>
</footer>
<main className="content">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,171 @@
.viz {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.1rem 0.9rem;
margin: 0;
box-shadow: var(--shadow);
}
.viz-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.viz-head h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 650;
color: var(--text-primary);
}
.viz-unit {
font-weight: 400;
color: var(--text-muted);
font-size: 0.85em;
}
.viz-sub {
margin: 0.2rem 0 0;
font-size: 0.78rem;
color: var(--text-muted);
}
.viz-toggle {
flex-shrink: 0;
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: 6px;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
cursor: pointer;
font-family: inherit;
}
.viz-toggle:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
.viz-empty {
height: 120px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.85rem;
background: var(--surface-0);
border-radius: 6px;
}
.viz-foot {
margin-top: 0.6rem;
padding-top: 0.6rem;
border-top: 1px solid var(--border);
font-size: 0.78rem;
color: var(--text-muted);
}
/* Identity is never colour alone: the swatch always sits beside a text label. */
.viz-swatch {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.4rem;
vertical-align: baseline;
}
.viz-legend-item {
color: var(--text-secondary);
font-size: 0.78rem;
}
/* Tooltip ------------------------------------------------------------------ */
.viz-tooltip {
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 0.55rem 0.7rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
font-size: 0.8rem;
min-width: 140px;
}
.viz-tooltip-label {
color: var(--text-muted);
font-size: 0.72rem;
margin-bottom: 0.35rem;
}
.viz-tooltip-row {
display: flex;
align-items: center;
gap: 0.1rem;
line-height: 1.7;
}
.viz-tooltip-name {
color: var(--text-secondary);
flex: 1;
margin-right: 0.75rem;
}
/* Values wear text tokens, never the series colour. */
.viz-tooltip-value {
color: var(--text-primary);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Table view --------------------------------------------------------------- */
.viz-table-wrap {
max-height: 260px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 6px;
}
.viz-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.viz-table th,
.viz-table td {
padding: 0.4rem 0.6rem;
text-align: right;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
white-space: nowrap;
}
.viz-table thead th {
position: sticky;
top: 0;
background: var(--surface-2);
color: var(--text-muted);
font-weight: 600;
font-size: 0.74rem;
text-align: right;
}
.viz-table thead th:first-child,
.viz-table tbody th {
text-align: left;
}
.viz-table tbody th {
color: var(--text-primary);
font-weight: 500;
}
.viz-table td {
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}

View File

@@ -0,0 +1,277 @@
import { ReactNode, useState } from 'react';
import {
Area, AreaChart, Bar, BarChart, CartesianGrid, Legend, Line, LineChart,
ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import './Chart.css';
export interface Series {
key: string;
label: string;
/** 1-based slot in the categorical palette. Assigned in order, never cycled. */
slot: 1 | 2 | 3 | 4 | 5 | 6;
unit?: string;
/** Round displayed values to this many decimals. */
decimals?: number;
}
interface ChartProps {
title: string;
subtitle?: string;
data: Array<Record<string, any>>;
series: Series[];
type: 'line' | 'bar' | 'stacked-bar' | 'area';
xKey?: string;
height?: number;
/** Y-axis label; a chart has exactly one axis — never two scales. */
unit?: string;
footer?: ReactNode;
}
const fmt = (value: any, decimals = 0) =>
value == null
? '—'
: typeof value === 'number'
? value.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
: String(value);
function TooltipBox({ active, payload, label, series }: any) {
if (!active || !payload?.length) return null;
return (
<div className="viz-tooltip">
<div className="viz-tooltip-label">{label}</div>
{payload.map((entry: any) => {
const s = series.find((x: Series) => x.key === entry.dataKey);
return (
<div key={entry.dataKey} className="viz-tooltip-row">
<span
className="viz-swatch"
style={{ background: entry.color }}
aria-hidden="true"
/>
<span className="viz-tooltip-name">{s?.label ?? entry.dataKey}</span>
<span className="viz-tooltip-value">
{fmt(entry.value, s?.decimals)}
{s?.unit ? ` ${s.unit}` : ''}
</span>
</div>
);
})}
</div>
);
}
function Chart({
title, subtitle, data, series, type, xKey = 'date', height = 240, unit, footer,
}: ChartProps) {
// A table view is the relief for series whose colour falls below 3:1 on the
// light surface, and doubles as the non-visual reading of any chart.
const [showTable, setShowTable] = useState(false);
const present = series.filter((s) => data.some((row) => row[s.key] != null));
if (present.length === 0) {
return (
<figure className="viz">
<figcaption className="viz-head">
<h4>{title}</h4>
</figcaption>
<div className="viz-empty"></div>
</figure>
);
}
const color = (s: Series) => `var(--series-${s.slot})`;
const axis = {
stroke: 'var(--border-strong)',
tick: { fill: 'var(--text-muted)', fontSize: 11 },
tickLine: false,
};
const margin = { top: 8, right: 8, bottom: 0, left: -8 };
// A legend is mandatory from two series up; a single series is named by the
// title, so a legend box would only repeat it.
const legend =
present.length > 1 ? (
<Legend
verticalAlign="top"
align="left"
height={28}
iconType="circle"
iconSize={8}
formatter={(value: string) => {
const s = present.find((x) => x.key === value);
return <span className="viz-legend-item">{s?.label ?? value}</span>;
}}
/>
) : null;
const grid = <CartesianGrid stroke="var(--grid)" vertical={false} />;
const tip = (
<Tooltip
content={<TooltipBox series={present} />}
cursor={{ stroke: 'var(--border-strong)', strokeWidth: 1 }}
/>
);
const render = () => {
if (type === 'bar' || type === 'stacked-bar') {
const stacked = type === 'stacked-bar';
return (
<BarChart data={data} margin={margin} barCategoryGap="22%">
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} />
{tip}
{legend}
{present.map((s, i) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.key}
fill={color(s)}
stackId={stacked ? 'stack' : undefined}
// 4px rounded data-end on the topmost segment only, so the shape
// reads as one bar anchored to the baseline.
radius={
stacked
? i === present.length - 1
? [4, 4, 0, 0]
: [0, 0, 0, 0]
: [4, 4, 0, 0]
}
// A 2px gap in the surface colour separates adjacent fills.
stroke="var(--surface-1)"
strokeWidth={stacked ? 2 : 0}
isAnimationActive={false}
/>
))}
</BarChart>
);
}
if (type === 'area') {
return (
<AreaChart data={data} margin={margin}>
<defs>
{present.map((s) => (
<linearGradient key={s.key} id={`fill-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color(s)} stopOpacity={0.22} />
<stop offset="100%" stopColor={color(s)} stopOpacity={0.02} />
</linearGradient>
))}
</defs>
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} />
{tip}
{legend}
{present.map((s) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.key}
stroke={color(s)}
strokeWidth={2}
fill={`url(#fill-${s.key})`}
connectNulls
isAnimationActive={false}
/>
))}
</AreaChart>
);
}
return (
<LineChart data={data} margin={margin}>
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} domain={['auto', 'auto']} />
{tip}
{legend}
{present.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.key}
stroke={color(s)}
strokeWidth={2}
dot={false}
// A 2px surface ring keeps overlapping markers readable.
activeDot={{ r: 5, strokeWidth: 2, stroke: 'var(--surface-1)' }}
// Days the device recorded nothing must not fragment the line.
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
);
};
return (
<figure className="viz">
<figcaption className="viz-head">
<div>
<h4>
{title}
{unit && <span className="viz-unit"> ({unit})</span>}
</h4>
{subtitle && <p className="viz-sub">{subtitle}</p>}
</div>
<button
type="button"
className="viz-toggle"
onClick={() => setShowTable((v) => !v)}
aria-pressed={showTable}
>
{showTable ? '看图表' : '看数据'}
</button>
</figcaption>
{showTable ? (
<div className="viz-table-wrap">
<table className="viz-table">
<thead>
<tr>
<th scope="col"></th>
{present.map((s) => (
<th key={s.key} scope="col">
<span
className="viz-swatch"
style={{ background: color(s) }}
aria-hidden="true"
/>
{s.label}
{s.unit ? ` (${s.unit})` : ''}
</th>
))}
</tr>
</thead>
<tbody>
{[...data].reverse().map((row, i) => (
<tr key={`${row[xKey]}-${i}`}>
<th scope="row">{row[xKey]}</th>
{present.map((s) => (
<td key={s.key}>{fmt(row[s.key], s.decimals)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<ResponsiveContainer width="100%" height={height}>
{render()}
</ResponsiveContainer>
)}
{footer && <div className="viz-foot">{footer}</div>}
</figure>
);
}
export default Chart;

View File

@@ -0,0 +1,80 @@
.tile {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem 1rem;
box-shadow: var(--shadow);
}
.tile-label {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.35rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-value {
font-size: 1.55rem;
font-weight: 650;
color: var(--text-primary);
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
.tile-unit {
font-size: 0.72rem;
font-weight: 400;
color: var(--text-muted);
margin-left: 0.25rem;
}
.tile-meter {
height: 4px;
background: var(--surface-0);
border-radius: 999px;
margin-top: 0.55rem;
overflow: hidden;
}
.tile-meter-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
}
.tile-detail {
margin-top: 0.45rem;
font-size: 0.74rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.tile-status {
font-weight: 600;
white-space: nowrap;
}
.status-good { color: var(--status-good); }
.status-warning { color: var(--status-warning); }
.status-serious { color: var(--status-serious); }
.status-critical { color: var(--status-critical); }
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 0.75rem;
}
@media (max-width: 560px) {
.tile-grid {
grid-template-columns: repeat(2, 1fr);
}
.tile-value {
font-size: 1.3rem;
}
}

View File

@@ -0,0 +1,75 @@
import { ReactNode } from 'react';
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. */
const STATUS_ICON: Record<Status, string> = {
good: '●',
warning: '▲',
serious: '▲',
critical: '■',
};
interface StatTileProps {
label: string;
value: number | string | null | undefined;
unit?: string;
/** Secondary line: a goal, a range, a comparison. */
detail?: ReactNode;
status?: Status;
statusLabel?: string;
/** 01; draws a goal meter under the value. */
progress?: number | null;
}
function StatTile({
label, value, unit, detail, status, statusLabel, progress,
}: StatTileProps) {
const display =
value == null
? '—'
: typeof value === 'number'
? value.toLocaleString(undefined, { maximumFractionDigits: 1 })
: value;
return (
<div className="tile">
<div className="tile-label">{label}</div>
<div className="tile-value">
{display}
{unit && value != null && <span className="tile-unit">{unit}</span>}
</div>
{progress != null && (
<div
className="tile-meter"
role="meter"
aria-valuenow={Math.round(progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${label}完成度`}
>
<div
className="tile-meter-fill"
style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
/>
</div>
)}
{(detail || status) && (
<div className="tile-detail">
{status && (
<span className={`tile-status status-${status}`}>
<span aria-hidden="true">{STATUS_ICON[status]}</span> {statusLabel}
</span>
)}
{detail}
</div>
)}
</div>
);
}
export default StatTile;