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>
</>
);
}
@@ -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">D1D7D30 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">
title="推荐增长漏斗" <SectionHeader
description="分享、打开、绑定、激活与奖励" title="推荐增长漏斗"
steps={data.referralFunnel} description="分享、打开、绑定、激活与奖励"
/> 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 自然量",
+39 -186
View File
@@ -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> </div>
<div className="divide-y divide-border"> <ComparisonBarChart
<SummaryRow items={[
icon={UserPlus} { label: "周期赠送", value: data.creditsGranted },
label="新增用户" { label: "周期消耗", value: data.creditsUsed },
value={formatNumber(data.newUsers)} ]}
/> primaryLabel="积分"
<SummaryRow primaryTone="warning"
icon={CreditCard} />
label="赠送 / 消耗"
value={`${formatNumber(data.creditsGranted)} / ${formatNumber(data.creditsUsed)}`}
/>
<SummaryRow
icon={ArrowUpRight}
label="使用类型"
value={`${formatNumber(data.usage.length)}`}
/>
</div>
</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,60 +118,71 @@ 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> </div>
<FunnelChart steps={data.funnel} />
</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> </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 />
</span> </span>
</div> </div>
<DataTable <ComparisonBarChart
data={data.ranking} items={data.ranking.slice(0, 8).map((item, index) => ({
columns={columns} label: `${index + 1} 名 · ${shortUserId(item.userId)}`,
caption="有效邀请用户排行" value: item.invited,
emptyTitle="当前周期暂无排行数据" secondaryValue: item.qualified,
hint: `${formatNumber(item.creditsEarned)} 奖励积分`,
}))}
primaryLabel="邀请"
secondaryLabel="有效"
primaryTone="warning"
secondaryTone="success"
emptyText="当前周期暂无排行数据"
/> />
</Card> </Card>
</section> </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>
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
<Award className="size-4" aria-hidden />
</span>
</div>
<DataTable
data={data.ranking}
columns={columns}
caption="有效邀请用户排行"
emptyTitle="当前周期暂无排行数据"
/>
</Card>
<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">
<Users className="size-5" aria-hidden /> <Users className="size-5" aria-hidden />
@@ -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)}`;
}
+153
View File
@@ -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,
+34 -1
View File
@@ -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),