[阶段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

@@ -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;