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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,11 +11,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { adminApi } from "../../api/client";
|
import { adminApi } from "../../api/client";
|
||||||
import type {
|
import type { AnalyticsRate, ProductAnalyticsOverview } from "../../api/types";
|
||||||
AnalyticsRate,
|
import { CohortHeatmap } from "../../components/charts/cohort-heatmap";
|
||||||
FunnelStep,
|
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||||
ProductAnalyticsOverview,
|
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||||
} from "../../api/types";
|
|
||||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||||
import { RangeControl } from "../../components/range-control";
|
import { RangeControl } from "../../components/range-control";
|
||||||
import { formatNumber } from "../../lib/format";
|
import { formatNumber } from "../../lib/format";
|
||||||
@@ -87,52 +86,21 @@ export function AnalyticsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
<section className="grid gap-6 xl:grid-cols-2">
|
||||||
<FunnelCard
|
|
||||||
title="增长激活漏斗"
|
|
||||||
description="首次启动到首次 AI 成功"
|
|
||||||
steps={data.growthFunnel}
|
|
||||||
/>
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="留存 Cohort"
|
title="增长激活漏斗"
|
||||||
|
description="首次启动到首次 AI 成功,显示逐步及累计转化"
|
||||||
|
icon={Target}
|
||||||
|
/>
|
||||||
|
<FunnelChart steps={data.growthFunnel} />
|
||||||
|
</Card>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="留存 Cohort 热力图"
|
||||||
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
||||||
icon={ChartNoAxesCombined}
|
icon={ChartNoAxesCombined}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<CohortHeatmap cohorts={data.retention} />
|
||||||
<table className="w-full min-w-[560px] border-collapse text-sm">
|
|
||||||
<caption className="sr-only">D1、D7、D30 价值留存 cohort</caption>
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border bg-surface-muted/70 text-left text-xs text-muted">
|
|
||||||
<th className="px-5 py-3 font-semibold" scope="col">激活日期</th>
|
|
||||||
<th className="px-4 py-3 text-right font-semibold" scope="col">用户</th>
|
|
||||||
<th className="px-4 py-3 text-right font-semibold" scope="col">D1</th>
|
|
||||||
<th className="px-4 py-3 text-right font-semibold" scope="col">D7</th>
|
|
||||||
<th className="px-5 py-3 text-right font-semibold" scope="col">D30</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.retention.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className="px-5 py-10 text-center text-muted" colSpan={5}>
|
|
||||||
当前周期暂无已激活 cohort
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
data.retention.map((cohort) => (
|
|
||||||
<tr className="border-b border-border last:border-0" key={cohort.cohortDate}>
|
|
||||||
<th className="px-5 py-3 text-left font-medium" scope="row">
|
|
||||||
{cohort.cohortDate}
|
|
||||||
</th>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums">{formatNumber(cohort.size)}</td>
|
|
||||||
<RetentionCell rate={cohort.d1} />
|
|
||||||
<RetentionCell rate={cohort.d7} />
|
|
||||||
<RetentionCell rate={cohort.d30} last />
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -146,24 +114,21 @@ export function AnalyticsPage() {
|
|||||||
{data.aiFeatures.length === 0 ? (
|
{data.aiFeatures.length === 0 ? (
|
||||||
<p className="p-8 text-center text-sm text-muted">等待客户端接入事件后显示</p>
|
<p className="p-8 text-center text-sm text-muted">等待客户端接入事件后显示</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid divide-y divide-border sm:grid-cols-2 sm:divide-x sm:divide-y-0">
|
<ComparisonBarChart
|
||||||
{data.aiFeatures.map((item) => (
|
items={[...data.aiFeatures]
|
||||||
<div className="p-5" key={`${item.feature}-${item.executionMode}`}>
|
.sort((left, right) => right.successes - left.successes)
|
||||||
<div className="flex items-center justify-between gap-3">
|
.map((item) => ({
|
||||||
<strong className="text-sm">{featureLabel(item.feature)}</strong>
|
id: `${item.feature}-${item.executionMode}`,
|
||||||
<span className="rounded-full bg-violet-soft px-2.5 py-1 text-xs font-semibold text-violet">
|
label: featureLabel(item.feature),
|
||||||
{executionModeLabel(item.executionMode)}
|
value: item.successes,
|
||||||
</span>
|
secondaryValue: item.users,
|
||||||
</div>
|
hint: executionModeLabel(item.executionMode),
|
||||||
<p className="mt-4 text-2xl font-bold tabular-nums">
|
}))}
|
||||||
{formatNumber(item.successes)}
|
primaryLabel="成功次数"
|
||||||
</p>
|
secondaryLabel="成功用户"
|
||||||
<p className="mt-1 text-xs text-muted">
|
primaryTone="violet"
|
||||||
{formatNumber(item.users)} 位成功用户
|
secondaryTone="success"
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -173,9 +138,17 @@ export function AnalyticsPage() {
|
|||||||
description="托管调用以服务端结算为准"
|
description="托管调用以服务端结算为准"
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
/>
|
/>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{ label: "DAU", value: data.activity.dau },
|
||||||
|
{ label: "WAU", value: data.activity.wau },
|
||||||
|
{ label: "MAU", value: data.activity.mau },
|
||||||
|
]}
|
||||||
|
primaryLabel="AI 活跃用户"
|
||||||
|
primaryTone="success"
|
||||||
|
/>
|
||||||
<MetricRows
|
<MetricRows
|
||||||
rows={[
|
rows={[
|
||||||
["AI DAU / WAU / MAU", `${data.activity.dau} / ${data.activity.wau} / ${data.activity.mau}`],
|
|
||||||
["DAU / MAU", optionalPercent(data.activity.stickinessPercent)],
|
["DAU / MAU", optionalPercent(data.activity.stickinessPercent)],
|
||||||
["成功 AI 次数", formatNumber(data.activity.successfulAiRequests)],
|
["成功 AI 次数", formatNumber(data.activity.successfulAiRequests)],
|
||||||
["人均成功次数", optionalDecimal(data.activity.successfulRequestsPerActiveUser)],
|
["人均成功次数", optionalDecimal(data.activity.successfulRequestsPerActiveUser)],
|
||||||
@@ -189,30 +162,62 @@ export function AnalyticsPage() {
|
|||||||
<section className="grid gap-6 xl:grid-cols-3">
|
<section className="grid gap-6 xl:grid-cols-3">
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<SectionHeader title="付费转化" description="StoreKit 已验证交易" icon={BadgeDollarSign} />
|
<SectionHeader title="付费转化" description="StoreKit 已验证交易" icon={BadgeDollarSign} />
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "7 天免费转付费",
|
||||||
|
value: data.monetization.conversion7d.percent ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "30 天免费转付费",
|
||||||
|
value: data.monetization.conversion30d.percent ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "复购率",
|
||||||
|
value: data.monetization.repeatPurchaseRate.percent ?? 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
primaryLabel="转化率"
|
||||||
|
primaryTone="success"
|
||||||
|
valueFormatter={(value) => `${value.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
<MetricRows
|
<MetricRows
|
||||||
rows={[
|
rows={[
|
||||||
["7 天免费转付费", rateLabel(data.monetization.conversion7d)],
|
|
||||||
["30 天免费转付费", rateLabel(data.monetization.conversion30d)],
|
|
||||||
["付费用户", formatNumber(data.monetization.payingUsers)],
|
["付费用户", formatNumber(data.monetization.payingUsers)],
|
||||||
["购买次数", formatNumber(data.monetization.purchases)],
|
["购买次数", formatNumber(data.monetization.purchases)],
|
||||||
["复购率", rateLabel(data.monetization.repeatPurchaseRate)],
|
|
||||||
["购买积分", formatNumber(data.monetization.creditsPurchased)],
|
["购买积分", formatNumber(data.monetization.creditsPurchased)],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<FunnelCard
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
title="推荐增长漏斗"
|
title="推荐增长漏斗"
|
||||||
description="分享、打开、绑定、激活与奖励"
|
description="分享、打开、绑定、激活与奖励"
|
||||||
steps={data.referralFunnel}
|
icon={Target}
|
||||||
/>
|
/>
|
||||||
|
<FunnelChart steps={data.referralFunnel} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<SectionHeader title="体验护栏" description="避免增长被失败体验抵消" icon={Users} />
|
<SectionHeader title="体验护栏" description="避免增长被失败体验抵消" icon={Users} />
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "客户端 AI",
|
||||||
|
value: data.guardrails.clientAiSuccessRate.percent ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "托管请求",
|
||||||
|
value: data.guardrails.managedSuccessRate.percent ?? 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
primaryLabel="成功率"
|
||||||
|
primaryTone="primary"
|
||||||
|
valueFormatter={(value) => `${value.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
<MetricRows
|
<MetricRows
|
||||||
rows={[
|
rows={[
|
||||||
["客户端 AI 成功率", rateLabel(data.guardrails.clientAiSuccessRate)],
|
|
||||||
["托管请求成功率", rateLabel(data.guardrails.managedSuccessRate)],
|
|
||||||
["积分不足阻断用户", formatNumber(data.guardrails.creditBlockedUsers)],
|
["积分不足阻断用户", formatNumber(data.guardrails.creditBlockedUsers)],
|
||||||
["首次价值耗时中位数", optionalMinutes(data.growth.medianTimeToValueMinutes)],
|
["首次价值耗时中位数", optionalMinutes(data.growth.medianTimeToValueMinutes)],
|
||||||
]}
|
]}
|
||||||
@@ -222,22 +227,25 @@ export function AnalyticsPage() {
|
|||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
||||||
<div className="grid divide-y divide-border sm:grid-cols-2 sm:divide-x sm:divide-y-0 xl:grid-cols-4">
|
<div className="grid gap-0 xl:grid-cols-[1fr_260px]">
|
||||||
{data.growth.channels.length === 0 ? (
|
<ComparisonBarChart
|
||||||
<p className="col-span-full p-8 text-center text-sm text-muted">暂无渠道归因数据</p>
|
items={data.growth.channels.map((channel) => ({
|
||||||
) : (
|
label: channelLabel(channel.channel),
|
||||||
data.growth.channels.map((channel) => (
|
value: channel.installations,
|
||||||
<div className="p-5" key={channel.channel}>
|
secondaryValue: channel.activated,
|
||||||
<strong className="text-sm">{channelLabel(channel.channel)}</strong>
|
hint: `激活率 ${rateLabel(channel.activationRate)}`,
|
||||||
<p className="mt-4 text-2xl font-bold tabular-nums">
|
}))}
|
||||||
{formatNumber(channel.installations)}
|
primaryLabel="新增安装"
|
||||||
</p>
|
secondaryLabel="24 小时激活"
|
||||||
<p className="mt-1 text-xs text-muted">
|
emptyText="暂无渠道归因数据"
|
||||||
激活 {rateLabel(channel.activationRate)} · {formatNumber(channel.activated)} 人
|
/>
|
||||||
</p>
|
<MetricRows
|
||||||
</div>
|
rows={[
|
||||||
))
|
["新增安装", formatNumber(data.growth.newInstallations)],
|
||||||
)}
|
["新增账号", formatNumber(data.growth.newAccounts)],
|
||||||
|
["整体激活率", rateLabel(data.growth.activation24h)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,43 +274,6 @@ function SectionHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FunnelCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
steps,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
steps: FunnelStep[];
|
|
||||||
}) {
|
|
||||||
const maximum = Math.max(steps[0]?.count ?? 0, 1);
|
|
||||||
return (
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<SectionHeader title={title} description={description} icon={Target} />
|
|
||||||
<div className="space-y-5 p-5">
|
|
||||||
{steps.length === 0 ? (
|
|
||||||
<p className="py-8 text-center text-sm text-muted">当前周期暂无漏斗数据</p>
|
|
||||||
) : (
|
|
||||||
steps.map((step) => (
|
|
||||||
<div key={step.label}>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="text-muted">{step.label}</span>
|
|
||||||
<strong className="tabular-nums">{formatNumber(step.count)}</strong>
|
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
className="funnel-progress block h-2.5 w-full overflow-hidden rounded-full"
|
|
||||||
max={maximum}
|
|
||||||
value={step.count}
|
|
||||||
aria-label={`${step.label}:${formatNumber(step.count)}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricRows({ rows }: { rows: Array<[string, string]> }) {
|
function MetricRows({ rows }: { rows: Array<[string, string]> }) {
|
||||||
return (
|
return (
|
||||||
<dl className="divide-y divide-border">
|
<dl className="divide-y divide-border">
|
||||||
@@ -316,14 +287,6 @@ function MetricRows({ rows }: { rows: Array<[string, string]> }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RetentionCell({ rate, last = false }: { rate?: AnalyticsRate; last?: boolean }) {
|
|
||||||
return (
|
|
||||||
<td className={`${last ? "px-5" : "px-4"} py-3 text-right`}>
|
|
||||||
<span className={retentionTone(rate?.percent)}>{rateLabel(rate)}</span>
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rateLabel(rate?: AnalyticsRate): string {
|
function rateLabel(rate?: AnalyticsRate): string {
|
||||||
return rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`;
|
return rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
@@ -355,13 +318,6 @@ function latestMatureRetention(
|
|||||||
return rateLabel(data.retention.find((cohort) => cohort[key]?.percent != null)?.[key]);
|
return rateLabel(data.retention.find((cohort) => cohort[key]?.percent != null)?.[key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function retentionTone(percent?: number): string {
|
|
||||||
if (percent == null) return "text-muted";
|
|
||||||
if (percent >= 40) return "rounded-md bg-success-soft px-2 py-1 text-success";
|
|
||||||
if (percent >= 20) return "rounded-md bg-warning-soft px-2 py-1 text-warning";
|
|
||||||
return "rounded-md bg-danger-soft px-2 py-1 text-danger";
|
|
||||||
}
|
|
||||||
|
|
||||||
function channelLabel(channel: string): string {
|
function channelLabel(channel: string): string {
|
||||||
return {
|
return {
|
||||||
APP_STORE_ORGANIC: "App Store 自然量",
|
APP_STORE_ORGANIC: "App Store 自然量",
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUpRight,
|
|
||||||
Coins,
|
Coins,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Sparkles,
|
|
||||||
UserPlus,
|
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { adminApi } from "../../api/client";
|
import { adminApi } from "../../api/client";
|
||||||
import type { Overview, TrendPoint } from "../../api/types";
|
import type { Overview } from "../../api/types";
|
||||||
|
import { ChartLegend } from "../../components/charts/chart-legend";
|
||||||
|
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||||
|
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||||
|
import { TrendChart } from "../../components/charts/trend-chart";
|
||||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||||
import { RangeControl } from "../../components/range-control";
|
import { RangeControl } from "../../components/range-control";
|
||||||
import { formatNumber, usageTypeLabel } from "../../lib/format";
|
import { formatNumber, usageTypeLabel } from "../../lib/format";
|
||||||
@@ -85,10 +86,12 @@ export function OverviewPage() {
|
|||||||
<h2 className="text-base font-bold text-foreground">增长与消耗趋势</h2>
|
<h2 className="text-base font-bold text-foreground">增长与消耗趋势</h2>
|
||||||
<p className="mt-1 text-xs text-muted">按 UTC 日期统计,双指标独立缩放</p>
|
<p className="mt-1 text-xs text-muted">按 UTC 日期统计,双指标独立缩放</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs text-muted">
|
<ChartLegend
|
||||||
<Legend color="bg-primary" label="新增用户" />
|
items={[
|
||||||
<Legend color="bg-violet" label="积分消耗" />
|
{ label: "新增用户", tone: "primary" },
|
||||||
</div>
|
{ label: "积分消耗", tone: "violet" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[320px] w-full">
|
<div className="h-[320px] w-full">
|
||||||
<TrendChart points={data.trend} />
|
<TrendChart points={data.trend} />
|
||||||
@@ -97,33 +100,25 @@ export function OverviewPage() {
|
|||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="border-b border-border p-5 sm:p-6">
|
<div className="border-b border-border p-5 sm:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<h2 className="text-base font-bold text-foreground">运营效率</h2>
|
||||||
<div>
|
<p className="mt-1 text-xs text-muted">活跃覆盖与积分流转对比</p>
|
||||||
<h2 className="text-base font-bold text-foreground">周期摘要</h2>
|
<div className="mt-5">
|
||||||
<p className="mt-1 text-xs text-muted">当前统计窗口</p>
|
<RadialMetric
|
||||||
</div>
|
label="用户活跃率"
|
||||||
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
|
percent={activeRate}
|
||||||
<Sparkles className="size-4" aria-hidden />
|
detail={`${formatNumber(data.activeUsers)} / ${formatNumber(data.totalUsers)} 位用户活跃`}
|
||||||
</span>
|
tone="success"
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
<SummaryRow
|
|
||||||
icon={UserPlus}
|
|
||||||
label="新增用户"
|
|
||||||
value={formatNumber(data.newUsers)}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
icon={CreditCard}
|
|
||||||
label="赠送 / 消耗"
|
|
||||||
value={`${formatNumber(data.creditsGranted)} / ${formatNumber(data.creditsUsed)}`}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
icon={ArrowUpRight}
|
|
||||||
label="使用类型"
|
|
||||||
value={`${formatNumber(data.usage.length)} 类`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{ label: "周期赠送", value: data.creditsGranted },
|
||||||
|
{ label: "周期消耗", value: data.creditsUsed },
|
||||||
|
]}
|
||||||
|
primaryLabel="积分"
|
||||||
|
primaryTone="warning"
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -137,161 +132,19 @@ export function OverviewPage() {
|
|||||||
{data.usage.length === 0 ? (
|
{data.usage.length === 0 ? (
|
||||||
<div className="p-8 text-center text-sm text-muted">当前周期暂无使用记录</div>
|
<div className="p-8 text-center text-sm text-muted">当前周期暂无使用记录</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid divide-y divide-border md:grid-cols-2 md:divide-x md:divide-y-0 xl:grid-cols-3">
|
<ComparisonBarChart
|
||||||
{data.usage.map((item) => (
|
items={[...data.usage]
|
||||||
<div key={item.kind} className="p-5 sm:p-6">
|
.sort((left, right) => right.chargedCredits - left.chargedCredits)
|
||||||
<div className="flex items-center justify-between">
|
.map((item) => ({
|
||||||
<span className="rounded-full bg-violet-soft px-2.5 py-1 text-xs font-semibold text-violet">
|
label: usageTypeLabel(item.kind),
|
||||||
{usageTypeLabel(item.kind)}
|
value: item.chargedCredits,
|
||||||
</span>
|
hint: `${formatNumber(item.requests)} 次请求`,
|
||||||
<span className="text-xs text-muted">{formatNumber(item.requests)} 请求</span>
|
}))}
|
||||||
</div>
|
primaryLabel="已消耗积分"
|
||||||
<strong className="mt-5 block text-2xl font-bold tracking-tight tabular-nums">
|
primaryTone="violet"
|
||||||
{formatNumber(item.chargedCredits)}
|
/>
|
||||||
</strong>
|
|
||||||
<span className="mt-1 block text-xs text-muted">已消耗积分</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Legend({ color, label }: { color: string; label: string }) {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<i className={`size-2 rounded-full ${color}`} aria-hidden />
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryRow({
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: typeof Users;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 px-5 py-4 sm:px-6">
|
|
||||||
<span className="grid size-9 place-items-center rounded-xl bg-surface-muted text-muted">
|
|
||||||
<Icon className="size-4" aria-hidden />
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted">{label}</span>
|
|
||||||
<strong className="ml-auto text-sm font-semibold tabular-nums text-foreground">{value}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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="chart-scroll 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-r
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { adminApi } from "../../api/client";
|
import { adminApi } from "../../api/client";
|
||||||
import type { ReferralOverview, ReferralRankingItem } from "../../api/types";
|
import type { ReferralOverview, ReferralRankingItem } from "../../api/types";
|
||||||
|
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||||
|
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||||
|
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -115,46 +118,58 @@ export function ReferralsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
|
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
|
||||||
<Card className="p-5 sm:p-6">
|
<Card className="overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between border-b border-border p-5 sm:p-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold">裂变漏斗</h2>
|
<h2 className="text-base font-bold">裂变漏斗</h2>
|
||||||
<p className="mt-1 text-xs text-muted">从分享触达到有效使用</p>
|
<p className="mt-1 text-xs text-muted">从分享触达到有效使用,逐层观察流失</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
|
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
|
||||||
<GitBranch className="size-4" aria-hidden />
|
<GitBranch className="size-4" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 space-y-6">
|
<div className="border-b border-border p-5 sm:p-6">
|
||||||
{data.funnel.length === 0 ? (
|
<RadialMetric
|
||||||
<p className="py-16 text-center text-sm text-muted">当前周期暂无漏斗数据</p>
|
label="整体有效转化"
|
||||||
) : (
|
percent={conversion}
|
||||||
data.funnel.map((step, index) => {
|
detail={`${formatNumber(first)} 个起点行为,最终形成 ${formatNumber(last)} 次有效使用`}
|
||||||
const max = Math.max(data.funnel[0]?.count ?? 1, 1);
|
tone="success"
|
||||||
return (
|
|
||||||
<div key={`${step.label}-${index}`}>
|
|
||||||
<div className="mb-2.5 flex items-center justify-between text-sm">
|
|
||||||
<span className="font-medium text-muted">{step.label}</span>
|
|
||||||
<strong className="tabular-nums">{formatNumber(step.count)}</strong>
|
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
className="funnel-progress block h-3 w-full overflow-hidden rounded-full"
|
|
||||||
max={max}
|
|
||||||
value={step.count}
|
|
||||||
aria-label={`${step.label}:${formatNumber(step.count)}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
<FunnelChart steps={data.funnel} />
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
|
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold">邀请排行</h2>
|
<h2 className="text-base font-bold">头部邀请贡献</h2>
|
||||||
<p className="mt-1 text-xs text-muted">按有效邀请数与奖励积分排序</p>
|
<p className="mt-1 text-xs text-muted">比较邀请总数与达到奖励条件的有效邀请</p>
|
||||||
|
</div>
|
||||||
|
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
|
||||||
|
<Award className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={data.ranking.slice(0, 8).map((item, index) => ({
|
||||||
|
label: `第 ${index + 1} 名 · ${shortUserId(item.userId)}`,
|
||||||
|
value: item.invited,
|
||||||
|
secondaryValue: item.qualified,
|
||||||
|
hint: `${formatNumber(item.creditsEarned)} 奖励积分`,
|
||||||
|
}))}
|
||||||
|
primaryLabel="邀请"
|
||||||
|
secondaryLabel="有效"
|
||||||
|
primaryTone="warning"
|
||||||
|
secondaryTone="success"
|
||||||
|
emptyText="当前周期暂无排行数据"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">邀请排行明细</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">保留完整用户维度,便于运营核对</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
|
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
|
||||||
<Award className="size-4" aria-hidden />
|
<Award className="size-4" aria-hidden />
|
||||||
@@ -167,7 +182,6 @@ export function ReferralsPage() {
|
|||||||
emptyTitle="当前周期暂无排行数据"
|
emptyTitle="当前周期暂无排行数据"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
|
||||||
|
|
||||||
<Card className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:p-6">
|
<Card className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:p-6">
|
||||||
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-success-soft text-success">
|
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-success-soft text-success">
|
||||||
@@ -199,3 +213,7 @@ function Rank({ value }: { value: number }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortUserId(userId: string): string {
|
||||||
|
return userId.length <= 12 ? userId : `${userId.slice(0, 6)}…${userId.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -304,26 +304,179 @@ button:disabled {
|
|||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-legend-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend-dot--primary {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend-dot--violet {
|
||||||
|
background: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend-dot--success {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend-dot--warning {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-progress,
|
||||||
.funnel-progress {
|
.funnel-progress {
|
||||||
border: 0;
|
border: 0;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bar-progress::-webkit-progress-bar,
|
||||||
.funnel-progress::-webkit-progress-bar {
|
.funnel-progress::-webkit-progress-bar {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bar-progress::-webkit-progress-value,
|
||||||
.funnel-progress::-webkit-progress-value {
|
.funnel-progress::-webkit-progress-value {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-progress--primary::-webkit-progress-value {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-progress--violet::-webkit-progress-value {
|
||||||
|
background: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-progress--success::-webkit-progress-value {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-progress--warning::-webkit-progress-value {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.funnel-progress::-webkit-progress-value {
|
||||||
background: linear-gradient(90deg, var(--primary), var(--violet));
|
background: linear-gradient(90deg, var(--primary), var(--violet));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bar-progress::-moz-progress-bar,
|
||||||
.funnel-progress::-moz-progress-bar {
|
.funnel-progress::-moz-progress-bar {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-progress--primary::-moz-progress-bar {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-progress--violet::-moz-progress-bar {
|
||||||
|
background: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-progress--success::-moz-progress-bar {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-progress--warning::-moz-progress-bar {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.funnel-progress::-moz-progress-bar {
|
||||||
background: linear-gradient(90deg, var(--primary), var(--violet));
|
background: linear-gradient(90deg, var(--primary), var(--violet));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radial-track,
|
||||||
|
.radial-value {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radial-track {
|
||||||
|
stroke: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radial-value {
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radial-value--primary {
|
||||||
|
stroke: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radial-value--success {
|
||||||
|
stroke: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radial-value--violet {
|
||||||
|
stroke: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radial-value--warning {
|
||||||
|
stroke: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radial-label {
|
||||||
|
fill: var(--foreground);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell {
|
||||||
|
display: block;
|
||||||
|
min-width: 4.25rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
padding: 0.65rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell--high {
|
||||||
|
color: var(--success);
|
||||||
|
background: var(--success-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell--medium {
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell--low {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell--unavailable {
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-key {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-key--high {
|
||||||
|
background: var(--success-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-key--medium {
|
||||||
|
background: var(--warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-key--low {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-key--unavailable {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { adminApi, ApiError } from "../api/client";
|
import { adminApi, ApiError } from "../api/client";
|
||||||
import type { UserDetail } from "../api/types";
|
import type { UserDetail } from "../api/types";
|
||||||
import { TrendChart } from "../features/overview/overview-page";
|
import { CohortHeatmap } from "../components/charts/cohort-heatmap";
|
||||||
|
import { ComparisonBarChart } from "../components/charts/comparison-bar-chart";
|
||||||
|
import { FunnelChart } from "../components/charts/funnel-chart";
|
||||||
|
import { RadialMetric } from "../components/charts/radial-metric";
|
||||||
|
import { TrendChart } from "../components/charts/trend-chart";
|
||||||
import { GrantDialog } from "../features/users/users-page";
|
import { GrantDialog } from "../features/users/users-page";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -28,6 +32,35 @@ describe("高风险交互与 CSP", () => {
|
|||||||
expect(screen.getByText("35")).toBeTruthy();
|
expect(screen.getByText("35")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("比较、漏斗、留存与环形图保持可访问且不生成内联样式", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[{ label: "自然量", value: 100, secondaryValue: 60 }]}
|
||||||
|
primaryLabel="新增安装"
|
||||||
|
secondaryLabel="24 小时激活"
|
||||||
|
/>
|
||||||
|
<FunnelChart steps={[{ label: "首次启动", count: 100 }]} />
|
||||||
|
<CohortHeatmap
|
||||||
|
cohorts={[
|
||||||
|
{
|
||||||
|
cohortDate: "2026-08-01",
|
||||||
|
size: 20,
|
||||||
|
d1: { numerator: 10, denominator: 20, percent: 50 },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<RadialMetric label="用户活跃率" percent={50} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("progressbar", { name: "自然量 新增安装:100" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("progressbar", { name: /首次启动:100/ })).toBeTruthy();
|
||||||
|
expect(screen.getByText("D1、D7、D30 价值留存 cohort 热力图")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "用户活跃率:50.0%" })).toBeTruthy();
|
||||||
|
expect(container.querySelector("[style]")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("赠送结果未知时保持弹窗并复用同一幂等键重试", async () => {
|
it("赠送结果未知时保持弹窗并复用同一幂等键重试", async () => {
|
||||||
const grant = vi.spyOn(adminApi, "grantCredits").mockRejectedValue(
|
const grant = vi.spyOn(adminApi, "grantCredits").mockRejectedValue(
|
||||||
new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0),
|
new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0),
|
||||||
|
|||||||
Reference in New Issue
Block a user