0b4acb5978
Turn operational, product, and referral metrics into accessible charts so trends, funnels, retention, and channel quality are faster to interpret.
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import {
|
|
Activity,
|
|
BadgeDollarSign,
|
|
BrainCircuit,
|
|
ChartNoAxesCombined,
|
|
Gauge,
|
|
Repeat2,
|
|
Sparkles,
|
|
Target,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { adminApi } from "../../api/client";
|
|
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";
|
|
|
|
export function AnalyticsPage() {
|
|
const [range, setRange] = useState("30d");
|
|
const [data, setData] = useState<ProductAnalyticsOverview>();
|
|
const [error, setError] = useState<unknown>();
|
|
|
|
const load = useCallback(async () => {
|
|
setError(undefined);
|
|
try {
|
|
setData(await adminApi.productAnalytics(range));
|
|
} catch (requestError) {
|
|
setError(requestError);
|
|
}
|
|
}, [range]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
|
if (!data) return <LoadingState label="加载产品数据" />;
|
|
|
|
const weeklyGrowth = data.northStar.weekOverWeekPercent;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
eyebrow="CEO Dashboard"
|
|
title="产品增长与留存"
|
|
description="围绕成功使用 AI 的核心价值事件,观察增长质量、留存、消耗与付费。"
|
|
actions={<RangeControl value={range} onChange={setRange} />}
|
|
/>
|
|
|
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="北极星指标">
|
|
<StatCard
|
|
label="周 AI 活跃用户"
|
|
value={formatNumber(data.northStar.weeklyAiActiveUsers)}
|
|
hint={
|
|
weeklyGrowth == null
|
|
? "暂无可比上周数据"
|
|
: `较上周 ${signedPercent(weeklyGrowth)}`
|
|
}
|
|
icon={Sparkles}
|
|
tone="success"
|
|
/>
|
|
<StatCard
|
|
label="24 小时激活率"
|
|
value={rateLabel(data.growth.activation24h)}
|
|
hint={`${formatNumber(data.growth.activation24h.numerator)} / ${formatNumber(data.growth.activation24h.denominator)} 位新用户`}
|
|
icon={Target}
|
|
/>
|
|
<StatCard
|
|
label="D7 价值留存"
|
|
value={latestMatureRetention(data, "d7")}
|
|
hint="激活后第 7 天再次成功使用 AI"
|
|
icon={Repeat2}
|
|
tone="violet"
|
|
/>
|
|
<StatCard
|
|
label="每活跃用户日均消耗"
|
|
value={optionalNumber(data.consumption.averageDailyCreditsPerActiveUser)}
|
|
hint={`周期总消耗 ${formatNumber(data.consumption.totalCredits)} 积分`}
|
|
icon={Gauge}
|
|
tone="warning"
|
|
/>
|
|
</section>
|
|
|
|
<section className="grid gap-6 xl:grid-cols-2">
|
|
<Card className="overflow-hidden">
|
|
<SectionHeader
|
|
title="增长激活漏斗"
|
|
description="首次启动到首次 AI 成功,显示逐步及累计转化"
|
|
icon={Target}
|
|
/>
|
|
<FunnelChart steps={data.growthFunnel} />
|
|
</Card>
|
|
<Card className="overflow-hidden">
|
|
<SectionHeader
|
|
title="留存 Cohort 热力图"
|
|
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
|
icon={ChartNoAxesCombined}
|
|
/>
|
|
<CohortHeatmap cohorts={data.retention} />
|
|
</Card>
|
|
</section>
|
|
|
|
<section className="grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
|
|
<Card className="overflow-hidden">
|
|
<SectionHeader
|
|
title="AI 使用结构"
|
|
description="客户端功能与执行模式,仅包含白名单元数据"
|
|
icon={BrainCircuit}
|
|
/>
|
|
{data.aiFeatures.length === 0 ? (
|
|
<p className="p-8 text-center text-sm text-muted">等待客户端接入事件后显示</p>
|
|
) : (
|
|
<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>
|
|
|
|
<Card className="overflow-hidden">
|
|
<SectionHeader
|
|
title="活跃与消耗"
|
|
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={[
|
|
["DAU / MAU", optionalPercent(data.activity.stickinessPercent)],
|
|
["成功 AI 次数", formatNumber(data.activity.successfulAiRequests)],
|
|
["人均成功次数", optionalDecimal(data.activity.successfulRequestsPerActiveUser)],
|
|
["用户日消耗中位数", optionalNumber(data.consumption.medianUserDailyCredits)],
|
|
["单次托管请求积分", optionalDecimal(data.consumption.averageCreditsPerManagedRequest)],
|
|
]}
|
|
/>
|
|
</Card>
|
|
</section>
|
|
|
|
<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={[
|
|
["付费用户", formatNumber(data.monetization.payingUsers)],
|
|
["购买次数", formatNumber(data.monetization.purchases)],
|
|
["购买积分", formatNumber(data.monetization.creditsPurchased)],
|
|
]}
|
|
/>
|
|
</Card>
|
|
|
|
<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={[
|
|
["积分不足阻断用户", formatNumber(data.guardrails.creditBlockedUsers)],
|
|
["首次价值耗时中位数", optionalMinutes(data.growth.medianTimeToValueMinutes)],
|
|
]}
|
|
/>
|
|
</Card>
|
|
</section>
|
|
|
|
<Card className="overflow-hidden">
|
|
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function SectionHeader({
|
|
title,
|
|
description,
|
|
icon: Icon,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
icon: typeof Activity;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between border-b border-border px-5 py-5">
|
|
<div>
|
|
<h2 className="text-base font-bold">{title}</h2>
|
|
<p className="mt-1 text-xs text-muted">{description}</p>
|
|
</div>
|
|
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
|
|
<Icon className="size-4" aria-hidden />
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricRows({ rows }: { rows: Array<[string, string]> }) {
|
|
return (
|
|
<dl className="divide-y divide-border">
|
|
{rows.map(([label, value]) => (
|
|
<div className="flex items-center justify-between gap-4 px-5 py-3.5" key={label}>
|
|
<dt className="text-sm text-muted">{label}</dt>
|
|
<dd className="text-right text-sm font-semibold tabular-nums">{value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
function rateLabel(rate?: AnalyticsRate): string {
|
|
return rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`;
|
|
}
|
|
|
|
function optionalPercent(value?: number): string {
|
|
return value == null ? "—" : `${value.toFixed(1)}%`;
|
|
}
|
|
|
|
function optionalDecimal(value?: number): string {
|
|
return value == null ? "—" : value.toFixed(2);
|
|
}
|
|
|
|
function optionalNumber(value?: number): string {
|
|
return value == null ? "—" : formatNumber(Math.round(value));
|
|
}
|
|
|
|
function optionalMinutes(value?: number): string {
|
|
return value == null ? "—" : `${Math.round(value)} 分钟`;
|
|
}
|
|
|
|
function signedPercent(value: number): string {
|
|
return `${value >= 0 ? "+" : ""}${value.toFixed(1)}%`;
|
|
}
|
|
|
|
function latestMatureRetention(
|
|
data: ProductAnalyticsOverview,
|
|
key: "d1" | "d7" | "d30",
|
|
): string {
|
|
return rateLabel(data.retention.find((cohort) => cohort[key]?.percent != null)?.[key]);
|
|
}
|
|
|
|
function channelLabel(channel: string): string {
|
|
return {
|
|
APP_STORE_ORGANIC: "App Store 自然量",
|
|
REFERRAL: "用户邀请",
|
|
SOCIAL_CONTENT: "社交 / 内容",
|
|
UNKNOWN: "未知来源",
|
|
}[channel] ?? channel;
|
|
}
|
|
|
|
function featureLabel(feature: string): string {
|
|
return {
|
|
TRANSCRIPTION: "语音转写",
|
|
POLISH: "文字润色",
|
|
AI_ASSISTANT: "AI 助手",
|
|
AGENT: "Agent",
|
|
HOTWORD: "快捷指令",
|
|
OTHER: "其他",
|
|
}[feature] ?? feature;
|
|
}
|
|
|
|
function executionModeLabel(mode: string): string {
|
|
return {
|
|
MANAGED: "托管",
|
|
LOCAL: "本地",
|
|
BYOK: "BYOK",
|
|
}[mode] ?? mode;
|
|
}
|