Visualize admin analytics dashboards
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Turn operational, product, and referral metrics into accessible charts so trends, funnels, retention, and channel quality are faster to interpret.
This commit is contained in:
Rocky
2026-08-20 16:02:21 +08:00
parent 4b465e0e5e
commit 0b4acb5978
11 changed files with 779 additions and 362 deletions
@@ -0,0 +1,16 @@
export function ChartLegend({
items,
}: {
items: Array<{ label: string; tone: "primary" | "violet" | "success" | "warning" }>;
}) {
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
{items.map((item) => (
<span className="flex items-center gap-2" key={`${item.label}-${item.tone}`}>
<i className={`chart-legend-dot chart-legend-dot--${item.tone}`} aria-hidden />
{item.label}
</span>
))}
</div>
);
}
@@ -0,0 +1,76 @@
import type { AnalyticsCohort, AnalyticsRate } from "../../api/types";
import { formatNumber } from "../../lib/format";
export function CohortHeatmap({ cohorts }: { cohorts: AnalyticsCohort[] }) {
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] border-separate border-spacing-x-1 border-spacing-y-1.5 px-4 py-4 text-sm">
<caption className="sr-only">D1D7D30 cohort </caption>
<thead>
<tr className="text-left text-xs text-muted">
<th className="px-2 py-2 font-semibold" scope="col"></th>
<th className="px-2 py-2 text-right font-semibold" scope="col"></th>
<th className="px-2 py-2 text-center font-semibold" scope="col">D1</th>
<th className="px-2 py-2 text-center font-semibold" scope="col">D7</th>
<th className="px-2 py-2 text-center font-semibold" scope="col">D30</th>
</tr>
</thead>
<tbody>
{cohorts.length === 0 ? (
<tr>
<td className="px-2 py-10 text-center text-muted" colSpan={5}>
cohort
</td>
</tr>
) : (
cohorts.map((cohort) => (
<tr key={cohort.cohortDate}>
<th className="px-2 py-2 text-left font-medium" scope="row">
{cohort.cohortDate}
</th>
<td className="px-2 py-2 text-right tabular-nums">{formatNumber(cohort.size)}</td>
<HeatmapCell rate={cohort.d1} />
<HeatmapCell rate={cohort.d7} />
<HeatmapCell rate={cohort.d30} />
</tr>
))
)}
</tbody>
</table>
<div className="flex flex-wrap items-center justify-end gap-3 px-5 pb-5 text-xs text-muted" aria-hidden>
<span></span>
<i className="heatmap-key heatmap-key--low" />
<i className="heatmap-key heatmap-key--medium" />
<i className="heatmap-key heatmap-key--high" />
<span></span>
<i className="heatmap-key heatmap-key--unavailable" />
<span></span>
</div>
</div>
);
}
function HeatmapCell({ rate }: { rate?: AnalyticsRate }) {
const label = rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`;
return (
<td className="p-0.5 text-center">
<span
className={`heatmap-cell ${heatmapTone(rate?.percent)}`}
title={
rate == null
? "留存窗口尚未成熟"
: `${formatNumber(rate.numerator)} / ${formatNumber(rate.denominator)}`
}
>
{label}
</span>
</td>
);
}
function heatmapTone(percent?: number): string {
if (percent == null) return "heatmap-cell--unavailable";
if (percent >= 40) return "heatmap-cell--high";
if (percent >= 20) return "heatmap-cell--medium";
return "heatmap-cell--low";
}
@@ -0,0 +1,103 @@
import { formatNumber } from "../../lib/format";
import { ChartLegend } from "./chart-legend";
type ChartTone = "primary" | "violet" | "success" | "warning";
export interface ComparisonBarItem {
id?: string;
label: string;
value: number;
secondaryValue?: number;
hint?: string;
}
export function ComparisonBarChart({
items,
primaryLabel,
secondaryLabel,
primaryTone = "primary",
secondaryTone = "violet",
valueFormatter = formatNumber,
emptyText = "当前周期暂无可比较数据",
}: {
items: ComparisonBarItem[];
primaryLabel: string;
secondaryLabel?: string;
primaryTone?: ChartTone;
secondaryTone?: ChartTone;
valueFormatter?: (value: number) => string;
emptyText?: string;
}) {
if (items.length === 0) {
return <p className="p-8 text-center text-sm text-muted">{emptyText}</p>;
}
const maximum = Math.max(
...items.flatMap((item) => [item.value, item.secondaryValue ?? 0]),
1,
);
const legend = [
{ label: primaryLabel, tone: primaryTone },
...(secondaryLabel ? [{ label: secondaryLabel, tone: secondaryTone }] : []),
];
return (
<div className="p-5 sm:p-6">
<ChartLegend items={legend} />
<div className="mt-6 space-y-5">
{items.map((item, index) => (
<div key={item.id ?? `${item.label}-${index}`}>
<div className="mb-2 flex items-baseline justify-between gap-4">
<span className="text-sm font-medium text-foreground">{item.label}</span>
{item.hint ? <span className="text-xs text-muted">{item.hint}</span> : null}
</div>
<Bar
label={`${item.label} ${primaryLabel}`}
maximum={maximum}
tone={primaryTone}
value={item.value}
valueLabel={valueFormatter(item.value)}
/>
{secondaryLabel != null && item.secondaryValue != null ? (
<Bar
label={`${item.label} ${secondaryLabel}`}
maximum={maximum}
tone={secondaryTone}
value={item.secondaryValue}
valueLabel={valueFormatter(item.secondaryValue)}
/>
) : null}
</div>
))}
</div>
</div>
);
}
function Bar({
label,
maximum,
tone,
value,
valueLabel,
}: {
label: string;
maximum: number;
tone: ChartTone;
value: number;
valueLabel: string;
}) {
return (
<div className="mt-1.5 flex items-center gap-3">
<progress
className={`bar-progress bar-progress--${tone} block h-2.5 min-w-0 flex-1 overflow-hidden rounded-full`}
max={maximum}
value={value}
aria-label={`${label}${valueLabel}`}
/>
<strong className="w-16 shrink-0 text-right text-xs font-semibold tabular-nums text-foreground">
{valueLabel}
</strong>
</div>
);
}
@@ -0,0 +1,54 @@
import type { FunnelStep } from "../../api/types";
import { formatNumber } from "../../lib/format";
export function FunnelChart({
steps,
emptyText = "当前周期暂无漏斗数据",
}: {
steps: FunnelStep[];
emptyText?: string;
}) {
if (steps.length === 0) {
return <p className="p-8 text-center text-sm text-muted">{emptyText}</p>;
}
const maximum = Math.max(steps[0]?.count ?? 0, ...steps.map((step) => step.count), 1);
return (
<div className="space-y-5 p-5 sm:p-6">
{steps.map((step, index) => {
const previous = steps[index - 1]?.count;
const stepConversion =
previous == null || previous === 0 ? undefined : (step.count / previous) * 100;
const totalConversion = maximum === 0 ? 0 : (step.count / maximum) * 100;
return (
<div key={`${step.label}-${index}`}>
<div className="mb-2 flex items-baseline justify-between gap-4">
<div className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">{step.label}</span>
<span className="mt-0.5 block text-xs text-muted">
{index === 0
? "漏斗起点"
: `上一步转化 ${stepConversion == null ? "—" : `${stepConversion.toFixed(1)}%`}`}
</span>
</div>
<div className="shrink-0 text-right">
<strong className="block text-sm tabular-nums">{formatNumber(step.count)}</strong>
<span className="text-xs tabular-nums text-muted">
{totalConversion.toFixed(1)}%
</span>
</div>
</div>
<progress
className="funnel-progress block h-3 w-full overflow-hidden rounded-full"
max={maximum}
value={step.count}
aria-label={`${step.label}${formatNumber(step.count)},占起点 ${totalConversion.toFixed(1)}%`}
/>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,44 @@
type RadialTone = "primary" | "success" | "violet" | "warning";
export function RadialMetric({
label,
percent,
detail,
tone = "primary",
}: {
label: string;
percent: number;
detail?: string;
tone?: RadialTone;
}) {
const value = Math.min(Math.max(percent, 0), 100);
return (
<div className="flex items-center gap-5">
<svg
className="size-28 shrink-0"
viewBox="0 0 120 120"
role="img"
aria-label={`${label}${value.toFixed(1)}%`}
>
<circle className="radial-track" cx="60" cy="60" r="48" pathLength="100" />
<circle
className={`radial-value radial-value--${tone}`}
cx="60"
cy="60"
r="48"
pathLength="100"
strokeDasharray={`${value} ${100 - value}`}
transform="rotate(-90 60 60)"
/>
<text className="radial-label" x="60" y="65" textAnchor="middle">
{Math.round(value)}%
</text>
</svg>
<div>
<strong className="text-sm text-foreground">{label}</strong>
{detail ? <p className="mt-1 text-xs leading-5 text-muted">{detail}</p> : null}
</div>
</div>
);
}
@@ -0,0 +1,111 @@
import type { TrendPoint } from "../../api/types";
import { formatNumber } from "../../lib/format";
export function TrendChart({ points }: { points: TrendPoint[] }) {
if (points.length === 0) {
return <div className="grid h-full place-items-center text-sm text-muted"></div>;
}
const width = 760;
const height = 300;
const paddingX = 42;
const paddingY = 30;
const plotHeight = height - paddingY * 2;
const step = points.length > 1 ? (width - paddingX * 2) / (points.length - 1) : 0;
const registrationMax = Math.max(...points.map((point) => point.registrations), 1);
const creditMax = Math.max(...points.map((point) => point.creditsUsed), 1);
const coordinates = points.map((point, index) => {
const x = points.length === 1 ? width / 2 : paddingX + index * step;
return {
point,
x,
registrationY:
height - paddingY - (point.registrations / registrationMax) * plotHeight,
creditY: height - paddingY - (point.creditsUsed / creditMax) * plotHeight,
};
});
const registrationLine = coordinates
.map(({ x, registrationY }) => `${x},${registrationY}`)
.join(" ");
const creditLine = coordinates.map(({ x, creditY }) => `${x},${creditY}`).join(" ");
const labelEvery = Math.max(Math.ceil(points.length / 6), 1);
return (
<>
<div className="h-full overflow-x-auto">
<svg
className="trend-chart block h-full min-w-[620px] overflow-visible"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-labelledby="overview-trend-title overview-trend-description"
>
<title id="overview-trend-title"></title>
<desc id="overview-trend-description">
UTC 线
</desc>
{[paddingY, height / 2, height - paddingY].map((y) => (
<line
key={y}
x1={paddingX}
x2={width - paddingX}
y1={y}
y2={y}
className="chart-grid-line"
/>
))}
<text x={paddingX} y={paddingY - 9} className="chart-label">
{formatNumber(registrationMax)}
</text>
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
{formatNumber(creditMax)}
</text>
<polyline points={registrationLine} className="chart-line chart-line--primary" />
<polyline points={creditLine} className="chart-line chart-line--violet" />
{coordinates.map(({ point, x, registrationY, creditY }, index) => (
<g key={point.date}>
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
<title>
{point.date} {formatNumber(point.registrations)}
</title>
</circle>
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
<title>
{point.date} {formatNumber(point.creditsUsed)}
</title>
</circle>
{index % labelEvery === 0 || index === points.length - 1 ? (
<text
x={x}
y={height - 5}
textAnchor={index === 0 ? "start" : index === points.length - 1 ? "end" : "middle"}
className="chart-label"
>
{point.date.slice(5)}
</text>
) : null}
</g>
))}
</svg>
</div>
<table className="sr-only">
<caption></caption>
<thead>
<tr>
<th scope="col">UTC </th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{points.map((point) => (
<tr key={point.date}>
<td>{point.date}</td>
<td>{formatNumber(point.registrations)}</td>
<td>{formatNumber(point.creditsUsed)}</td>
</tr>
))}
</tbody>
</table>
</>
);
}