Files
OSGAccountServer/admin-web/src/features/analytics/analytics-page.tsx
T
Rocky 0b4acb5978
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Visualize admin analytics dashboards
Turn operational, product, and referral metrics into accessible charts so trends, funnels, retention, and channel quality are faster to interpret.
2026-08-20 16:02:21 +08:00

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;
}