602 lines
21 KiB
TypeScript
602 lines
21 KiB
TypeScript
import {
|
||
Activity,
|
||
BadgeDollarSign,
|
||
BrainCircuit,
|
||
ChartNoAxesCombined,
|
||
Gauge,
|
||
Keyboard,
|
||
Repeat2,
|
||
Sparkles,
|
||
Target,
|
||
Users,
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { adminApi } from "../../api/client";
|
||
import type {
|
||
AnalyticsRate,
|
||
ProductAnalyticsOverview,
|
||
SortOrder,
|
||
} from "../../api/types";
|
||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||
import { CohortHeatmap } from "../../components/charts/cohort-heatmap";
|
||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||
import { FilterControl, ToggleFilter } from "../../components/filter-control";
|
||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||
import { PeriodCaption } from "../../components/period-caption";
|
||
import { RangeControl } from "../../components/range-control";
|
||
import { SortControl } from "../../components/sort-control";
|
||
import { formatNumber } from "../../lib/format";
|
||
import { stableSort } from "../../lib/sort";
|
||
|
||
export function AnalyticsPage() {
|
||
const [range, setRange] = useState("30d");
|
||
const [data, setData] = useState<ProductAnalyticsOverview>();
|
||
const [error, setError] = useState<unknown>();
|
||
const [executionMode, setExecutionMode] = useState("");
|
||
const [aiSort, setAiSort] = useState<"successes" | "users">("successes");
|
||
const [aiOrder, setAiOrder] = useState<SortOrder>("desc");
|
||
const [channelSort, setChannelSort] = useState<"installs" | "activated" | "rate">("installs");
|
||
const [channelOrder, setChannelOrder] = useState<SortOrder>("desc");
|
||
const [hideUnknown, setHideUnknown] = useState(false);
|
||
const [cohortOrder, setCohortOrder] = useState<SortOrder>("desc");
|
||
const [minimumCohortSize, setMinimumCohortSize] = useState(0);
|
||
const [matureOnly, setMatureOnly] = useState(false);
|
||
const requestVersion = useRef(0);
|
||
|
||
const load = useCallback(async () => {
|
||
const version = ++requestVersion.current;
|
||
setError(undefined);
|
||
try {
|
||
const response = await adminApi.productAnalytics(range);
|
||
if (requestVersion.current === version) setData(response);
|
||
} catch (requestError) {
|
||
if (requestVersion.current === version) setError(requestError);
|
||
}
|
||
}, [range]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
const visibleAiFeatures = useMemo(
|
||
() =>
|
||
stableSort(
|
||
(data?.aiFeatures ?? []).filter(
|
||
(item) => !executionMode || item.executionMode === executionMode,
|
||
),
|
||
(item) => (aiSort === "successes" ? item.successes : item.users),
|
||
aiOrder,
|
||
),
|
||
[aiOrder, aiSort, data?.aiFeatures, executionMode],
|
||
);
|
||
const visibleChannels = useMemo(
|
||
() =>
|
||
stableSort(
|
||
(data?.growth.channels ?? []).filter(
|
||
(channel) => !hideUnknown || channel.channel !== "UNKNOWN",
|
||
),
|
||
(channel) =>
|
||
channelSort === "installs"
|
||
? channel.installations
|
||
: channelSort === "activated"
|
||
? channel.activated
|
||
: channel.activationRate.percent ?? -1,
|
||
channelOrder,
|
||
),
|
||
[channelOrder, channelSort, data?.growth.channels, hideUnknown],
|
||
);
|
||
const visibleCohorts = useMemo(
|
||
() =>
|
||
stableSort(
|
||
(data?.retention ?? []).filter(
|
||
(cohort) =>
|
||
cohort.size >= minimumCohortSize &&
|
||
(!matureOnly || cohort.d30?.percent != null),
|
||
),
|
||
(cohort) => cohort.cohortDate,
|
||
cohortOrder,
|
||
),
|
||
[cohortOrder, data?.retention, matureOnly, minimumCohortSize],
|
||
);
|
||
|
||
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={
|
||
<div className="flex flex-col items-end gap-2">
|
||
<RangeControl value={range} onChange={setRange} />
|
||
<PeriodCaption from={data.period.from} until={data.period.until} />
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
<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="同一批已完成 24 小时观察的新安装,所有步骤必须在首次启动后 24 小时内完成"
|
||
icon={Target}
|
||
/>
|
||
<FunnelChart steps={data.growthFunnel} />
|
||
</Card>
|
||
<Card className="overflow-hidden">
|
||
<SectionHeader
|
||
title="留存 Cohort 热力图"
|
||
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
||
icon={ChartNoAxesCombined}
|
||
/>
|
||
<ChartToolbar label="留存 cohort 筛选与排序">
|
||
<SortControl
|
||
label="激活日期"
|
||
value="date"
|
||
order={cohortOrder}
|
||
options={[{ value: "date", label: "激活日期" }]}
|
||
onChange={(_, order) => setCohortOrder(order)}
|
||
/>
|
||
<FilterControl
|
||
label="最小样本量"
|
||
value={String(minimumCohortSize)}
|
||
options={[
|
||
{ value: "0", label: "不限" },
|
||
{ value: "10", label: "至少 10 人" },
|
||
{ value: "50", label: "至少 50 人" },
|
||
{ value: "100", label: "至少 100 人" },
|
||
]}
|
||
onChange={(value) => setMinimumCohortSize(Number(value))}
|
||
/>
|
||
<ToggleFilter label="仅显示 D30 已成熟" checked={matureOnly} onChange={setMatureOnly} />
|
||
</ChartToolbar>
|
||
<CohortHeatmap cohorts={visibleCohorts} />
|
||
</Card>
|
||
</section>
|
||
|
||
<section className="grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
|
||
<Card className="overflow-hidden">
|
||
<SectionHeader
|
||
title="AI 使用结构"
|
||
description="客户端功能与执行模式,仅包含白名单元数据"
|
||
icon={BrainCircuit}
|
||
/>
|
||
<ChartToolbar label="AI 使用结构筛选与排序">
|
||
<FilterControl
|
||
label="执行模式"
|
||
value={executionMode}
|
||
options={[
|
||
{ value: "", label: "全部模式" },
|
||
{ value: "MANAGED", label: "托管" },
|
||
{ value: "LOCAL", label: "本地" },
|
||
{ value: "BYOK", label: "BYOK" },
|
||
]}
|
||
onChange={setExecutionMode}
|
||
/>
|
||
<SortControl
|
||
value={aiSort}
|
||
order={aiOrder}
|
||
options={[
|
||
{ value: "successes", label: "成功次数" },
|
||
{ value: "users", label: "成功用户" },
|
||
]}
|
||
onChange={(value, order) => {
|
||
setAiSort(value);
|
||
setAiOrder(order);
|
||
}}
|
||
/>
|
||
</ChartToolbar>
|
||
{visibleAiFeatures.length === 0 ? (
|
||
<p className="p-8 text-center text-sm text-muted">当前筛选条件下暂无 AI 使用数据</p>
|
||
) : (
|
||
<ComparisonBarChart
|
||
items={visibleAiFeatures.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="DAU / WAU / MAU 为截至统计截止时刻的滚动 1 / 7 / 30 日 AI 价值活跃用户"
|
||
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-2" aria-label="键盘输入统计">
|
||
<Card className="overflow-hidden">
|
||
<SectionHeader
|
||
title="键盘中英文输入"
|
||
description="仅统计 OSGKeyboard 本地聚合后的手动提交字符,不包含输入内容"
|
||
icon={Keyboard}
|
||
/>
|
||
<ComparisonBarChart
|
||
items={[
|
||
{
|
||
label: "中文",
|
||
value: data.keyboardUsage.chineseCharacters,
|
||
hint: `占中英文 ${optionalPercent(data.keyboardUsage.chineseSharePercent)}`,
|
||
},
|
||
{
|
||
label: "英文",
|
||
value: data.keyboardUsage.englishCharacters,
|
||
hint: `占中英文 ${optionalPercent(data.keyboardUsage.englishSharePercent)}`,
|
||
},
|
||
{
|
||
label: "其他",
|
||
value: data.keyboardUsage.otherCharacters,
|
||
hint: "数字、标点与 Emoji",
|
||
},
|
||
]}
|
||
primaryLabel="提交字符数"
|
||
primaryTone="primary"
|
||
/>
|
||
<MetricRows
|
||
rows={[
|
||
["输入活跃用户", formatNumber(data.keyboardUsage.activeUsers)],
|
||
["激活到输入转化", rateLabel(data.keyboardUsage.activationToInput)],
|
||
["中文活跃用户", formatNumber(data.keyboardUsage.chineseActiveUsers)],
|
||
["英文活跃用户", formatNumber(data.keyboardUsage.englishActiveUsers)],
|
||
["中英双语用户", formatNumber(data.keyboardUsage.bilingualActiveUsers)],
|
||
["总提交字符", formatNumber(data.keyboardUsage.totalCharacters)],
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
<Card className="overflow-hidden">
|
||
<SectionHeader
|
||
title="输入会话结构"
|
||
description="一次键盘激活期间至少提交一个字符才计为输入会话"
|
||
icon={Activity}
|
||
/>
|
||
<ComparisonBarChart
|
||
items={[
|
||
{ label: "仅中文", value: data.keyboardUsage.chineseOnlySessions },
|
||
{ label: "仅英文", value: data.keyboardUsage.englishOnlySessions },
|
||
{ label: "中英混合", value: data.keyboardUsage.mixedLanguageSessions },
|
||
{ label: "仅其他", value: data.keyboardUsage.otherOnlySessions },
|
||
]}
|
||
primaryLabel="输入会话"
|
||
primaryTone="success"
|
||
/>
|
||
<MetricRows
|
||
rows={[
|
||
["输入会话总数", formatNumber(data.keyboardUsage.inputSessions)],
|
||
[
|
||
"平均每会话字符",
|
||
optionalDecimal(data.keyboardUsage.averageCharactersPerInputSession),
|
||
],
|
||
]}
|
||
/>
|
||
</Card>
|
||
</section>
|
||
|
||
<section className="grid gap-6 xl:grid-cols-3">
|
||
<Card className="overflow-hidden">
|
||
<SectionHeader
|
||
title="付费转化"
|
||
description="所选周期内完成 7 / 30 天观察窗的注册 cohort;购买以 StoreKit 验证为准"
|
||
icon={BadgeDollarSign}
|
||
/>
|
||
<ComparisonBarChart
|
||
items={[
|
||
{
|
||
label: "7 天免费转付费",
|
||
value: data.monetization.conversion7d.percent ?? null,
|
||
},
|
||
{
|
||
label: "30 天免费转付费",
|
||
value: data.monetization.conversion30d.percent ?? null,
|
||
},
|
||
{
|
||
label: "复购率",
|
||
value: data.monetization.repeatPurchaseRate.percent ?? null,
|
||
},
|
||
]}
|
||
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="严格绑定 cohort;分享和打开仅作为独立方向信号"
|
||
icon={Target}
|
||
/>
|
||
<MetricRows
|
||
rows={[
|
||
["客户端分享信号", formatNumber(data.referralSignals.shared)],
|
||
["邀请打开信号", formatNumber(data.referralSignals.opened)],
|
||
]}
|
||
/>
|
||
<FunnelChart steps={data.referralFunnel} />
|
||
</Card>
|
||
|
||
<Card className="overflow-hidden">
|
||
<SectionHeader title="体验护栏" description="避免增长被失败体验抵消" icon={Users} />
|
||
<ComparisonBarChart
|
||
items={[
|
||
{
|
||
label: "客户端 AI",
|
||
value: data.guardrails.clientAiSuccessRate.percent ?? null,
|
||
},
|
||
{
|
||
label: "托管请求",
|
||
value: data.guardrails.managedSuccessRate.percent ?? null,
|
||
},
|
||
]}
|
||
primaryLabel="成功率"
|
||
primaryTone="primary"
|
||
valueFormatter={(value) => `${value.toFixed(1)}%`}
|
||
/>
|
||
<MetricRows
|
||
rows={[
|
||
["积分不足阻断用户", formatNumber(data.guardrails.creditBlockedUsers)],
|
||
["首次价值耗时中位数", optionalMinutes(data.growth.medianTimeToValueMinutes)],
|
||
]}
|
||
/>
|
||
</Card>
|
||
</section>
|
||
|
||
<section className="grid gap-6 xl:grid-cols-2">
|
||
<Card className="overflow-hidden">
|
||
<SectionHeader
|
||
title="购买意向漏斗"
|
||
description="同一安装依次浏览、发起购买,并由 StoreKit 服务端验证"
|
||
icon={BadgeDollarSign}
|
||
/>
|
||
<FunnelChart
|
||
steps={data.monetization.purchaseFunnel}
|
||
emptyText="客户端购买事件暂无样本"
|
||
/>
|
||
<MetricRows
|
||
rows={[["取消购买用户", formatNumber(data.monetization.cancelledUsers)]]}
|
||
/>
|
||
</Card>
|
||
|
||
<Card className="overflow-hidden">
|
||
<SectionHeader
|
||
title="AI 终态延迟分布"
|
||
description="按客户端白名单耗时桶聚合;不推算虚假的精确分位数"
|
||
icon={Gauge}
|
||
/>
|
||
<ComparisonBarChart
|
||
items={data.guardrails.latencyBuckets.map((item) => ({
|
||
id: item.bucket,
|
||
label: latencyBucketLabel(item.bucket),
|
||
value: item.successful,
|
||
secondaryValue: item.failed,
|
||
}))}
|
||
primaryLabel="成功"
|
||
secondaryLabel="失败"
|
||
primaryTone="success"
|
||
secondaryTone="warning"
|
||
emptyText="客户端 AI 终态事件暂无样本"
|
||
/>
|
||
</Card>
|
||
</section>
|
||
|
||
<Card className="overflow-hidden">
|
||
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
||
<ChartToolbar label="渠道质量筛选与排序">
|
||
<SortControl
|
||
value={channelSort}
|
||
order={channelOrder}
|
||
options={[
|
||
{ value: "installs", label: "已完成观察安装" },
|
||
{ value: "activated", label: "激活人数" },
|
||
{ value: "rate", label: "激活率" },
|
||
]}
|
||
onChange={(value, order) => {
|
||
setChannelSort(value);
|
||
setChannelOrder(order);
|
||
}}
|
||
/>
|
||
<ToggleFilter label="隐藏未知来源" checked={hideUnknown} onChange={setHideUnknown} />
|
||
</ChartToolbar>
|
||
<div className="grid gap-0 xl:grid-cols-[1fr_260px]">
|
||
<ComparisonBarChart
|
||
items={visibleChannels.map((channel) => ({
|
||
label: channelLabel(channel.channel),
|
||
value: channel.installations,
|
||
secondaryValue: channel.activated,
|
||
hint: `激活率 ${rateLabel(channel.activationRate)}`,
|
||
}))}
|
||
primaryLabel="已完成 24h 观察安装"
|
||
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;
|
||
}
|
||
|
||
function latencyBucketLabel(bucket: string): string {
|
||
return {
|
||
LT_1S: "< 1 秒",
|
||
S1_TO_3: "1–3 秒",
|
||
S3_TO_10: "3–10 秒",
|
||
S10_TO_30: "10–30 秒",
|
||
GTE_30S: "≥ 30 秒",
|
||
}[bucket] ?? bucket;
|
||
}
|