2026-06-08 01:20:00 +08:00

105 lines
3.9 KiB
JavaScript

export function MetricCard({ item, loading = false, onClick }) {
const Icon = item.icon;
const UnitIcon = item.unitIcon;
const clickable = Boolean(onClick);
if (loading) {
return (
<article className={["metric-card", Icon ? "" : "metric-card--no-icon", "is-loading"].filter(Boolean).join(" ")} aria-busy="true">
{Icon ? <div className="metric-icon metric-icon--skeleton"><span className="skeleton-block skeleton-icon" /></div> : null}
<div className="metric-content">
<span className="skeleton-block skeleton-label" />
<span className="skeleton-block skeleton-value" />
<span className="skeleton-block skeleton-caption" />
</div>
</article>
);
}
return (
<article
className={["metric-card", Icon ? "" : "metric-card--no-icon", clickable ? "metric-card--clickable" : ""].filter(Boolean).join(" ")}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={onClick}
onKeyDown={clickable ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
} : undefined}
>
<div className="metric-content">
<div className="metric-label-row">
{Icon ? <i className="metric-title-icon"><Icon /></i> : null}
<span>{item.label}</span>
{UnitIcon ? <i className="metric-unit-icon"><UnitIcon /></i> : null}
{item.unit ? <em className="metric-unit">{item.unit}</em> : null}
</div>
{Array.isArray(item.subMetrics) && item.subMetrics.length ? <MetricSubValues items={item.subMetrics} /> : <MetricMainValue item={item} />}
<MetricSparkline series={item.trend} />
</div>
</article>
);
}
function MetricMainValue({ item }) {
return (
<>
<strong>{item.value}</strong>
<MetricDelta item={item} />
</>
);
}
function MetricSubValues({ items }) {
return (
<div className="metric-sub-grid">
{items.map((subItem) => (
<div className="metric-sub-item" key={subItem.label}>
<span>{subItem.label}</span>
<strong>{subItem.value}</strong>
<MetricDelta item={subItem} compact />
</div>
))}
</div>
);
}
function MetricDelta({ compact = false, item }) {
const tone = item.deltaTone || (String(item.delta || "").trim().startsWith("-") ? "down" : "up");
return (
<div className={["metric-delta", compact ? "metric-delta--compact" : "", tone === "down" ? "metric-delta--down" : ""].filter(Boolean).join(" ")}>
<span>{item.caption}</span>
<b className={item.delta ? "" : "metric-delta-empty"}>{item.delta ? `${tone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}</b>
</div>
);
}
function MetricSparkline({ series }) {
const lines = Array.isArray(series) ? series.filter((item) => Array.isArray(item.data) && item.data.length > 1) : [];
if (!lines.length) {
return <span className="metric-sparkline metric-sparkline--empty" />;
}
const values = lines.flatMap((line) => line.data.map((point) => Number(point.value) || 0));
const min = Math.min(...values);
const max = Math.max(...values);
return (
<svg className="metric-sparkline" preserveAspectRatio="none" viewBox="0 0 100 24" aria-hidden="true">
{lines.slice(0, 2).map((line, index) => (
<polyline className={`metric-sparkline-line metric-sparkline-line--${index + 1}`} key={line.name || index} points={sparklinePoints(line.data, min, max)} />
))}
</svg>
);
}
function sparklinePoints(points, min, max) {
const width = 100;
const height = 24;
const count = points.length;
return points.map((point, index) => {
const x = count <= 1 ? width : (index / (count - 1)) * width;
const normalized = max <= min ? 0.5 : ((Number(point.value) || 0) - min) / (max - min);
const y = height - normalized * 18 - 3;
return `${x.toFixed(2)},${y.toFixed(2)}`;
}).join(" ");
}