Add privacy-safe product analytics
Establish an idempotent analytics pipeline and internal decision dashboard while keeping event metadata allowlisted and account deletion enforceable.
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
||||
LedgerEntry,
|
||||
Overview,
|
||||
PageResult,
|
||||
ProductAnalyticsOverview,
|
||||
ReferralOverview,
|
||||
SessionResponse,
|
||||
UserDetail,
|
||||
@@ -165,6 +166,9 @@ export const adminApi = {
|
||||
referrals: (range: string) =>
|
||||
request<ReferralOverview>(`/referrals${query({ range })}`),
|
||||
|
||||
productAnalytics: (range: string) =>
|
||||
request<ProductAnalyticsOverview>(`/analytics${query({ range })}`),
|
||||
|
||||
users: (search = "", cursor?: string) =>
|
||||
request<PageResult<UserSummary>>(
|
||||
`/users${query({ q: search.trim(), cursor })}`,
|
||||
|
||||
@@ -52,6 +52,84 @@ export interface ReferralOverview {
|
||||
ranking: ReferralRankingItem[];
|
||||
}
|
||||
|
||||
export interface AnalyticsRate {
|
||||
numerator: number;
|
||||
denominator: number;
|
||||
percent?: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsChannel {
|
||||
channel: string;
|
||||
installations: number;
|
||||
activated: number;
|
||||
activationRate: AnalyticsRate;
|
||||
}
|
||||
|
||||
export interface AnalyticsCohort {
|
||||
cohortDate: string;
|
||||
size: number;
|
||||
d1?: AnalyticsRate;
|
||||
d7?: AnalyticsRate;
|
||||
d30?: AnalyticsRate;
|
||||
}
|
||||
|
||||
export interface AnalyticsFeatureUsage {
|
||||
feature: string;
|
||||
executionMode: string;
|
||||
users: number;
|
||||
successes: number;
|
||||
}
|
||||
|
||||
export interface ProductAnalyticsOverview {
|
||||
period: {
|
||||
from: string;
|
||||
until: string;
|
||||
};
|
||||
northStar: {
|
||||
weeklyAiActiveUsers: number;
|
||||
previousWeeklyAiActiveUsers: number;
|
||||
weekOverWeekPercent?: number;
|
||||
};
|
||||
growth: {
|
||||
newInstallations: number;
|
||||
newAccounts: number;
|
||||
activation24h: AnalyticsRate;
|
||||
medianTimeToValueMinutes?: number;
|
||||
channels: AnalyticsChannel[];
|
||||
};
|
||||
activity: {
|
||||
dau: number;
|
||||
wau: number;
|
||||
mau: number;
|
||||
stickinessPercent?: number;
|
||||
successfulAiRequests: number;
|
||||
successfulRequestsPerActiveUser?: number;
|
||||
};
|
||||
consumption: {
|
||||
totalCredits: number;
|
||||
averageDailyCreditsPerActiveUser?: number;
|
||||
medianUserDailyCredits?: number;
|
||||
averageCreditsPerManagedRequest?: number;
|
||||
};
|
||||
monetization: {
|
||||
payingUsers: number;
|
||||
purchases: number;
|
||||
creditsPurchased: number;
|
||||
conversion7d: AnalyticsRate;
|
||||
conversion30d: AnalyticsRate;
|
||||
repeatPurchaseRate: AnalyticsRate;
|
||||
};
|
||||
growthFunnel: FunnelStep[];
|
||||
retention: AnalyticsCohort[];
|
||||
aiFeatures: AnalyticsFeatureUsage[];
|
||||
referralFunnel: FunnelStep[];
|
||||
guardrails: {
|
||||
clientAiSuccessRate: AnalyticsRate;
|
||||
managedSuccessRate: AnalyticsRate;
|
||||
creditBlockedUsers: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
BookOpenCheck,
|
||||
ChartNoAxesCombined,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
GitBranch,
|
||||
@@ -49,6 +50,11 @@ const ReferralsPage = lazy(() =>
|
||||
default: module.ReferralsPage,
|
||||
})),
|
||||
);
|
||||
const AnalyticsPage = lazy(() =>
|
||||
import("./features/analytics/analytics-page").then((module) => ({
|
||||
default: module.AnalyticsPage,
|
||||
})),
|
||||
);
|
||||
const UsersPage = lazy(() =>
|
||||
import("./features/users/users-page").then((module) => ({
|
||||
default: module.UsersPage,
|
||||
@@ -89,6 +95,13 @@ const navigation: NavItem[] = [
|
||||
icon: Activity,
|
||||
roles: allRoles,
|
||||
},
|
||||
{
|
||||
path: "/analytics",
|
||||
label: "产品分析",
|
||||
description: "增长、留存与付费",
|
||||
icon: ChartNoAxesCombined,
|
||||
roles: allRoles,
|
||||
},
|
||||
{
|
||||
path: "/referrals",
|
||||
label: "裂变分析",
|
||||
@@ -185,6 +198,7 @@ function AuthenticatedApp({ role }: { role: AdminRole }) {
|
||||
<Suspense fallback={<LoadingState label="加载页面" />}>
|
||||
<Routes>
|
||||
<Route path="/overview" element={<OverviewPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/referrals" element={<ReferralsPage />} />
|
||||
{supportRoles.includes(role) ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const ranges = [
|
||||
{ value: "7d", label: "7 天" },
|
||||
{ value: "30d", label: "30 天" },
|
||||
{ value: "90d", label: "90 天" },
|
||||
] as const;
|
||||
|
||||
export function RangeControl({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
||||
{ranges.map((range) => (
|
||||
<button
|
||||
key={range.value}
|
||||
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
|
||||
value === range.value
|
||||
? "bg-surface text-foreground shadow-sm"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => onChange(range.value)}
|
||||
type="button"
|
||||
aria-pressed={value === range.value}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
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,
|
||||
FunnelStep,
|
||||
ProductAnalyticsOverview,
|
||||
} from "../../api/types";
|
||||
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">
|
||||
<FunnelCard
|
||||
title="增长激活漏斗"
|
||||
description="首次启动到首次 AI 成功"
|
||||
steps={data.growthFunnel}
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="活跃与消耗"
|
||||
description="托管调用以服务端结算为准"
|
||||
icon={Activity}
|
||||
/>
|
||||
<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)],
|
||||
["用户日消耗中位数", 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} />
|
||||
<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={Users} />
|
||||
<MetricRows
|
||||
rows={[
|
||||
["客户端 AI 成功率", rateLabel(data.guardrails.clientAiSuccessRate)],
|
||||
["托管请求成功率", rateLabel(data.guardrails.managedSuccessRate)],
|
||||
["积分不足阻断用户", formatNumber(data.guardrails.creditBlockedUsers)],
|
||||
["首次价值耗时中位数", optionalMinutes(data.growth.medianTimeToValueMinutes)],
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</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 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">
|
||||
{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 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)}%`;
|
||||
}
|
||||
|
||||
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 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 自然量",
|
||||
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;
|
||||
}
|
||||
@@ -11,14 +11,9 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { Overview, TrendPoint } from "../../api/types";
|
||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { formatNumber, usageTypeLabel } from "../../lib/format";
|
||||
|
||||
const ranges = [
|
||||
{ value: "7d", label: "7 天" },
|
||||
{ value: "30d", label: "30 天" },
|
||||
{ value: "90d", label: "90 天" },
|
||||
] as const;
|
||||
|
||||
export function OverviewPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [data, setData] = useState<Overview>();
|
||||
@@ -164,34 +159,6 @@ export function OverviewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function RangeControl({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
||||
{ranges.map((range) => (
|
||||
<button
|
||||
key={range.value}
|
||||
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
|
||||
value === range.value
|
||||
? "bg-surface text-foreground shadow-sm"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => onChange(range.value)}
|
||||
type="button"
|
||||
aria-pressed={value === range.value}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PageHeader,
|
||||
StatCard,
|
||||
} from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
|
||||
export function ReferralsPage() {
|
||||
@@ -198,35 +199,3 @@ function Rank({ value }: { value: number }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeControl({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
||||
{[
|
||||
["7d", "7 天"],
|
||||
["30d", "30 天"],
|
||||
["90d", "90 天"],
|
||||
].map(([range, label]) => (
|
||||
<button
|
||||
key={range}
|
||||
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
|
||||
value === range
|
||||
? "bg-surface text-foreground shadow-sm"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => onChange(range ?? "30d")}
|
||||
type="button"
|
||||
aria-pressed={value === range}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ export function usageTypeLabel(usageType?: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
polish: "润色",
|
||||
asr: "ASR",
|
||||
ASR: "语音转写",
|
||||
ai: "AI",
|
||||
LLM: "AI 文本",
|
||||
agent: "Agent",
|
||||
hotword: "热词",
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AdminOperator,
|
||||
AdminSecuritySummary,
|
||||
LedgerEntry,
|
||||
ProductAnalyticsOverview,
|
||||
UserSummary,
|
||||
} from "../api/types";
|
||||
|
||||
@@ -85,6 +86,21 @@ describe("React 管理页面", () => {
|
||||
expect(await screen.findByText("support")).toBeTruthy();
|
||||
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("产品分析页展示北极星、留存与付费护栏", async () => {
|
||||
mockSession("SUPPORT");
|
||||
vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(analyticsOverview());
|
||||
window.location.hash = "#/analytics";
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "产品增长与留存" })).toBeTruthy();
|
||||
expect(screen.getByText("周 AI 活跃用户")).toBeTruthy();
|
||||
expect(screen.getByText(/较上周 \+12\.5%/)).toBeTruthy();
|
||||
expect(screen.getByText("2026-08-01")).toBeTruthy();
|
||||
expect(screen.getByText("文字润色")).toBeTruthy();
|
||||
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
|
||||
@@ -95,6 +111,82 @@ function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
|
||||
});
|
||||
}
|
||||
|
||||
function analyticsOverview(): ProductAnalyticsOverview {
|
||||
const rate = (numerator: number, denominator: number, percent: number) => ({
|
||||
numerator,
|
||||
denominator,
|
||||
percent,
|
||||
});
|
||||
return {
|
||||
period: { from: "2026-08-01T00:00:00Z", until: "2026-08-20T00:00:00Z" },
|
||||
northStar: {
|
||||
weeklyAiActiveUsers: 90,
|
||||
previousWeeklyAiActiveUsers: 80,
|
||||
weekOverWeekPercent: 12.5,
|
||||
},
|
||||
growth: {
|
||||
newInstallations: 100,
|
||||
newAccounts: 80,
|
||||
activation24h: rate(60, 100, 60),
|
||||
medianTimeToValueMinutes: 8,
|
||||
channels: [
|
||||
{
|
||||
channel: "APP_STORE_ORGANIC",
|
||||
installations: 100,
|
||||
activated: 60,
|
||||
activationRate: rate(60, 100, 60),
|
||||
},
|
||||
],
|
||||
},
|
||||
activity: {
|
||||
dau: 20,
|
||||
wau: 90,
|
||||
mau: 150,
|
||||
stickinessPercent: 13.3,
|
||||
successfulAiRequests: 500,
|
||||
successfulRequestsPerActiveUser: 3.3,
|
||||
},
|
||||
consumption: {
|
||||
totalCredits: 1_200,
|
||||
averageDailyCreditsPerActiveUser: 12,
|
||||
medianUserDailyCredits: 8,
|
||||
averageCreditsPerManagedRequest: 2.4,
|
||||
},
|
||||
monetization: {
|
||||
payingUsers: 10,
|
||||
purchases: 12,
|
||||
creditsPurchased: 8_000,
|
||||
conversion7d: rate(8, 70, 11.4),
|
||||
conversion30d: rate(10, 50, 20),
|
||||
repeatPurchaseRate: rate(2, 10, 20),
|
||||
},
|
||||
growthFunnel: [
|
||||
{ label: "首次启动", count: 100 },
|
||||
{ label: "24 小时内首次 AI 成功", count: 60 },
|
||||
],
|
||||
retention: [
|
||||
{
|
||||
cohortDate: "2026-08-01",
|
||||
size: 20,
|
||||
d1: rate(10, 20, 50),
|
||||
d7: rate(6, 20, 30),
|
||||
},
|
||||
],
|
||||
aiFeatures: [
|
||||
{ feature: "POLISH", executionMode: "MANAGED", users: 30, successes: 100 },
|
||||
],
|
||||
referralFunnel: [
|
||||
{ label: "发起分享", count: 20 },
|
||||
{ label: "完成奖励", count: 5 },
|
||||
],
|
||||
guardrails: {
|
||||
clientAiSuccessRate: rate(90, 100, 90),
|
||||
managedSuccessRate: rate(95, 100, 95),
|
||||
creditBlockedUsers: 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function user(displayName: string, id: string): UserSummary {
|
||||
return {
|
||||
userId: id,
|
||||
|
||||
Reference in New Issue
Block a user