From 79832dbc2b314d83009507c52459ba0d586d29ed Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 31 Mar 2026 10:43:01 +0800 Subject: [PATCH] Add analytics funnel cards to the ops dashboard --- frontend/components/ops/OpsDashboard.tsx | 162 ++++++++++++++++++++++- 1 file changed, 161 insertions(+), 1 deletion(-) diff --git a/frontend/components/ops/OpsDashboard.tsx b/frontend/components/ops/OpsDashboard.tsx index 08b8683a..a336458c 100644 --- a/frontend/components/ops/OpsDashboard.tsx +++ b/frontend/components/ops/OpsDashboard.tsx @@ -88,6 +88,19 @@ type AuthMePayload = { points?: number; }; +type AnalyticsFunnelStep = { + total?: number; + unique_users?: number; + unique_actors?: number; +}; + +type AnalyticsFunnelPayload = { + window_days?: number; + since?: string; + events?: Record; + rates?: Record; +}; + type OpsUser = { telegram_id: number; username?: string | null; @@ -164,6 +177,7 @@ export function OpsDashboard() { const [status, setStatus] = useState(null); const [payments, setPayments] = useState(null); const [auth, setAuth] = useState(null); + const [analyticsFunnel, setAnalyticsFunnel] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshedAt, setRefreshedAt] = useState(null); @@ -188,17 +202,19 @@ export function OpsDashboard() { setLoading(true); setError(null); try { - const [healthData, statusData, paymentData, authData] = await Promise.all([ + const [healthData, statusData, paymentData, authData, analyticsData] = await Promise.all([ readJson("/api/healthz"), readJson("/api/system/status"), readJson("/api/payments/runtime"), readJson("/api/auth/me"), + readJson("/api/ops/analytics/funnel?days=30"), ]); setHealth(healthData); setStatus(statusData); setPayments(paymentData); setAuth(authData); + setAnalyticsFunnel(analyticsData); setRefreshedAt(new Date().toISOString()); } catch (loadError) { setError(String(loadError)); @@ -298,6 +314,74 @@ export function OpsDashboard() { const rolloutDecision = status?.probability?.rollout?.decision; + const funnelSteps = useMemo(() => { + const events = analyticsFunnel?.events || {}; + const getStep = (key: string) => events[key] || {}; + const signups = getStep("signup_completed"); + const active = getStep("dashboard_active"); + const featureClicks = getStep("paywall_feature_clicked"); + const paywallViews = getStep("paywall_viewed"); + const checkoutStarted = getStep("checkout_started"); + const checkoutSucceeded = getStep("checkout_succeeded"); + const base = Math.max(active.unique_actors || 0, signups.unique_actors || 0, 1); + + return [ + { + key: "signup_completed", + label: "注册成功", + helper: "近 30 天首次试用用户", + count: signups.unique_actors || 0, + total: signups.total || 0, + width: `${Math.max(((signups.unique_actors || 0) / base) * 100, 10)}%`, + }, + { + key: "dashboard_active", + label: "登录活跃", + helper: "进入主面板并产生有效会话", + count: active.unique_actors || 0, + total: active.total || 0, + width: `${Math.max(((active.unique_actors || 0) / base) * 100, 10)}%`, + rateLabel: analyticsFunnel?.rates?.login_active_rate, + }, + { + key: "paywall_feature_clicked", + label: "点击受限功能", + helper: "今日日内分析 / 历史对账触发拦截", + count: featureClicks.unique_actors || 0, + total: featureClicks.total || 0, + width: `${Math.max(((featureClicks.unique_actors || 0) / base) * 100, 10)}%`, + rateLabel: analyticsFunnel?.rates?.paywall_click_rate, + }, + { + key: "paywall_viewed", + label: "看到付费入口", + helper: "功能弹窗或账户中心升级层", + count: paywallViews.unique_actors || 0, + total: paywallViews.total || 0, + width: `${Math.max(((paywallViews.unique_actors || 0) / base) * 100, 10)}%`, + rateLabel: analyticsFunnel?.rates?.paywall_view_rate, + }, + { + key: "checkout_started", + label: "发起支付", + helper: "创建支付 intent", + count: checkoutStarted.unique_actors || 0, + total: checkoutStarted.total || 0, + width: `${Math.max(((checkoutStarted.unique_actors || 0) / base) * 100, 10)}%`, + rateLabel: analyticsFunnel?.rates?.checkout_start_rate, + }, + { + key: "checkout_succeeded", + label: "支付成功", + helper: "链上确认并写入权益", + count: checkoutSucceeded.unique_actors || 0, + total: checkoutSucceeded.total || 0, + width: `${Math.max(((checkoutSucceeded.unique_actors || 0) / base) * 100, 10)}%`, + rateLabel: analyticsFunnel?.rates?.checkout_success_rate, + }, + ]; + }, [analyticsFunnel]); + const rolloutVariant = useMemo(() => { const decision = rolloutDecision?.decision; if (decision === "promote") return "success" as const; @@ -448,6 +532,82 @@ export function OpsDashboard() { +
+ + + 转化漏斗 + + 最近 {analyticsFunnel?.window_days || 30} 天从注册、活跃到付费的最小闭环。 + + + +
+ {funnelSteps.map((step) => ( +
+
+
+
{step.label}
+
{step.count}
+
+ {typeof step.rateLabel === "number" ? ( + {(step.rateLabel * 100).toFixed(1)}% + ) : null} +
+
{step.helper}
+
+
+
+
+ 事件 {step.total} 次 · 独立用户 {step.count} +
+
+ ))} +
+ + + + + + 漏斗速读 + 先看哪一层掉得最厉害,再决定改拦截、文案还是支付页。 + + +
+
窗口起点
+
{formatDateTime(analyticsFunnel?.since)}
+
+
+
+ 注册 → 活跃 + {((analyticsFunnel?.rates?.login_active_rate || 0) * 100).toFixed(1)}% +
+
+ 活跃 → 点受限功能 + {((analyticsFunnel?.rates?.paywall_click_rate || 0) * 100).toFixed(1)}% +
+
+ 点击 → 看到付费入口 + {((analyticsFunnel?.rates?.paywall_view_rate || 0) * 100).toFixed(1)}% +
+
+ 付费入口 → 发起支付 + {((analyticsFunnel?.rates?.checkout_start_rate || 0) * 100).toFixed(1)}% +
+
+ 发起支付 → 成功 + {((analyticsFunnel?.rates?.checkout_success_rate || 0) * 100).toFixed(1)}% +
+
+
+ 这些数字直接来自 /api/ops/analytics/funnel,后面如果要再拆“试用到期 / Pro 到期 / 游客”漏斗,可以在事件 payload 上继续加维度。 +
+
+
+
+