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:
@@ -11,11 +11,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type {
|
||||
AnalyticsRate,
|
||||
FunnelStep,
|
||||
ProductAnalyticsOverview,
|
||||
} from "../../api/types";
|
||||
import type { AnalyticsRate, ProductAnalyticsOverview } from "../../api/types";
|
||||
import { CohortHeatmap } from "../../components/charts/cohort-heatmap";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
@@ -87,52 +86,21 @@ export function AnalyticsPage() {
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<FunnelCard
|
||||
title="增长激活漏斗"
|
||||
description="首次启动到首次 AI 成功"
|
||||
steps={data.growthFunnel}
|
||||
/>
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="留存 Cohort"
|
||||
title="增长激活漏斗"
|
||||
description="首次启动到首次 AI 成功,显示逐步及累计转化"
|
||||
icon={Target}
|
||||
/>
|
||||
<FunnelChart steps={data.growthFunnel} />
|
||||
</Card>
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="留存 Cohort 热力图"
|
||||
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
||||
icon={ChartNoAxesCombined}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<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>
|
||||
<CohortHeatmap cohorts={data.retention} />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -146,24 +114,21 @@ export function AnalyticsPage() {
|
||||
{data.aiFeatures.length === 0 ? (
|
||||
<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">
|
||||
{data.aiFeatures.map((item) => (
|
||||
<div className="p-5" key={`${item.feature}-${item.executionMode}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<strong className="text-sm">{featureLabel(item.feature)}</strong>
|
||||
<span className="rounded-full bg-violet-soft px-2.5 py-1 text-xs font-semibold text-violet">
|
||||
{executionModeLabel(item.executionMode)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-4 text-2xl font-bold tabular-nums">
|
||||
{formatNumber(item.successes)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{formatNumber(item.users)} 位成功用户
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ComparisonBarChart
|
||||
items={[...data.aiFeatures]
|
||||
.sort((left, right) => right.successes - left.successes)
|
||||
.map((item) => ({
|
||||
id: `${item.feature}-${item.executionMode}`,
|
||||
label: featureLabel(item.feature),
|
||||
value: item.successes,
|
||||
secondaryValue: item.users,
|
||||
hint: executionModeLabel(item.executionMode),
|
||||
}))}
|
||||
primaryLabel="成功次数"
|
||||
secondaryLabel="成功用户"
|
||||
primaryTone="violet"
|
||||
secondaryTone="success"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -173,9 +138,17 @@ export function AnalyticsPage() {
|
||||
description="托管调用以服务端结算为准"
|
||||
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
|
||||
rows={[
|
||||
["AI DAU / WAU / MAU", `${data.activity.dau} / ${data.activity.wau} / ${data.activity.mau}`],
|
||||
["DAU / MAU", optionalPercent(data.activity.stickinessPercent)],
|
||||
["成功 AI 次数", formatNumber(data.activity.successfulAiRequests)],
|
||||
["人均成功次数", optionalDecimal(data.activity.successfulRequestsPerActiveUser)],
|
||||
@@ -189,30 +162,62 @@ export function AnalyticsPage() {
|
||||
<section className="grid gap-6 xl:grid-cols-3">
|
||||
<Card className="overflow-hidden">
|
||||
<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
|
||||
rows={[
|
||||
["7 天免费转付费", rateLabel(data.monetization.conversion7d)],
|
||||
["30 天免费转付费", rateLabel(data.monetization.conversion30d)],
|
||||
["付费用户", formatNumber(data.monetization.payingUsers)],
|
||||
["购买次数", formatNumber(data.monetization.purchases)],
|
||||
["复购率", rateLabel(data.monetization.repeatPurchaseRate)],
|
||||
["购买积分", formatNumber(data.monetization.creditsPurchased)],
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<FunnelCard
|
||||
title="推荐增长漏斗"
|
||||
description="分享、打开、绑定、激活与奖励"
|
||||
steps={data.referralFunnel}
|
||||
/>
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="推荐增长漏斗"
|
||||
description="分享、打开、绑定、激活与奖励"
|
||||
icon={Target}
|
||||
/>
|
||||
<FunnelChart steps={data.referralFunnel} />
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<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
|
||||
rows={[
|
||||
["客户端 AI 成功率", rateLabel(data.guardrails.clientAiSuccessRate)],
|
||||
["托管请求成功率", rateLabel(data.guardrails.managedSuccessRate)],
|
||||
["积分不足阻断用户", formatNumber(data.guardrails.creditBlockedUsers)],
|
||||
["首次价值耗时中位数", optionalMinutes(data.growth.medianTimeToValueMinutes)],
|
||||
]}
|
||||
@@ -222,22 +227,25 @@ export function AnalyticsPage() {
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<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">
|
||||
{data.growth.channels.length === 0 ? (
|
||||
<p className="col-span-full p-8 text-center text-sm text-muted">暂无渠道归因数据</p>
|
||||
) : (
|
||||
data.growth.channels.map((channel) => (
|
||||
<div className="p-5" key={channel.channel}>
|
||||
<strong className="text-sm">{channelLabel(channel.channel)}</strong>
|
||||
<p className="mt-4 text-2xl font-bold tabular-nums">
|
||||
{formatNumber(channel.installations)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
激活 {rateLabel(channel.activationRate)} · {formatNumber(channel.activated)} 人
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div className="grid gap-0 xl:grid-cols-[1fr_260px]">
|
||||
<ComparisonBarChart
|
||||
items={data.growth.channels.map((channel) => ({
|
||||
label: channelLabel(channel.channel),
|
||||
value: channel.installations,
|
||||
secondaryValue: channel.activated,
|
||||
hint: `激活率 ${rateLabel(channel.activationRate)}`,
|
||||
}))}
|
||||
primaryLabel="新增安装"
|
||||
secondaryLabel="24 小时激活"
|
||||
emptyText="暂无渠道归因数据"
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
["新增安装", formatNumber(data.growth.newInstallations)],
|
||||
["新增账号", formatNumber(data.growth.newAccounts)],
|
||||
["整体激活率", rateLabel(data.growth.activation24h)],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</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]> }) {
|
||||
return (
|
||||
<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 {
|
||||
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]);
|
||||
}
|
||||
|
||||
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 {
|
||||
return {
|
||||
APP_STORE_ORGANIC: "App Store 自然量",
|
||||
|
||||
Reference in New Issue
Block a user