Visualize admin analytics dashboards
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:
@@ -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">D1、D7、D30 价值留存 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user