From e1e000e8540ae05ed6a438ee0171148448c9017c Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 19 May 2026 19:28:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E4=BC=9A=E5=91=98=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A2=9E=E9=95=BF=E8=B6=8B=E5=8A=BF=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=EF=BC=9A=E7=B4=AF=E8=AE=A1=E6=9B=B2=E7=BA=BF=E3=80=81?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E6=96=B0=E5=A2=9E=E5=A0=86=E5=8F=A0=E9=9D=A2?= =?UTF-8?q?=E7=A7=AF=E5=9B=BE=E3=80=81=E7=BB=9F=E8=AE=A1=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/ops/memberships/growth/route.ts | 19 ++++ .../ops/memberships/MembershipsPageClient.tsx | 99 ++++++++++++++++--- web/routers/ops.py | 6 ++ web/services/ops_api.py | 49 +++++++++ 4 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 frontend/app/api/ops/memberships/growth/route.ts diff --git a/frontend/app/api/ops/memberships/growth/route.ts b/frontend/app/api/ops/memberships/growth/route.ts new file mode 100644 index 00000000..14d09028 --- /dev/null +++ b/frontend/app/api/ops/memberships/growth/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 }); + try { + const auth = await buildBackendRequestHeaders(req); + const url = new URL(`${API_BASE}/api/ops/memberships/growth`); + const days = req.nextUrl.searchParams.get("days"); + if (days) url.searchParams.set("days", days); + const res = await fetch(url.toString(), { headers: auth.headers, cache: "no-store" }); + const raw = await res.text(); + const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); + return applyAuthResponseCookies(response, auth.response); + } catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Growth fetch failed" }); } +} diff --git a/frontend/components/ops/memberships/MembershipsPageClient.tsx b/frontend/components/ops/memberships/MembershipsPageClient.tsx index e6c081ca..8f934a4d 100644 --- a/frontend/components/ops/memberships/MembershipsPageClient.tsx +++ b/frontend/components/ops/memberships/MembershipsPageClient.tsx @@ -6,17 +6,28 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { opsApi } from "@/lib/ops-api"; import type { MembershipEntry } from "@/types/ops"; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, Legend, Area, AreaChart, +} from "recharts"; + +type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number }; export function MembershipsPageClient() { const [loading, setLoading] = useState(true); const [memberships, setMemberships] = useState([]); + const [growth, setGrowth] = useState([]); const [filter, setFilter] = useState<"all" | "paid" | "trial">("all"); const load = async () => { setLoading(true); try { - const data = await opsApi.memberships(); - setMemberships((data as unknown as { memberships?: MembershipEntry[] }).memberships ?? []); + const [mData, gData] = await Promise.all([ + opsApi.memberships(), + fetch("/api/ops/memberships/growth?days=90", { cache: "no-store" }).then(r => r.ok ? r.json() : null), + ]); + setMemberships((mData as unknown as { memberships?: MembershipEntry[] }).memberships ?? []); + setGrowth((gData as { daily?: GrowthPoint[] })?.daily ?? []); } catch { /* */ } setLoading(false); }; @@ -49,12 +60,7 @@ export function MembershipsPageClient() {
{(["all", "paid", "trial"] as const).map((f) => ( - ))} @@ -63,6 +69,75 @@ export function MembershipsPageClient() {
+ + {/* Growth chart */} + {growth.length > 0 && ( + + + 会员增长趋势 — 近 {growth.length} 天 + + +
+
+
+ {growth.reduce((s, d) => s + d.total, 0)} +
+
总新增
+
+
+
+ {growth[growth.length - 1]?.cumulative ?? 0} +
+
当前累计
+
+
+
+ {(growth.reduce((s, d) => s + d.total, 0) / Math.max(1, growth.filter(d => d.total > 0).length)).toFixed(1)} +
+
日均新增
+
+
+
+ {Math.max(...growth.map(d => d.total), 0)} +
+
单日最高
+
+
+ + {/* Cumulative area chart */} +
+

累计会员

+ + + + + + + + + +
+ + {/* Daily bars */} +
+

每日新增

+ + + + + + + + + + + +
+
+
+ )} + + {/* Table */}
@@ -82,13 +157,9 @@ export function MembershipsPageClient() { {m.is_trial ? ( - - 体验 - + 体验 ) : ( - - 付费 - + 付费 )} {m.email ?? "—"} diff --git a/web/routers/ops.py b/web/routers/ops.py index 28fd8de8..3acff0f6 100644 --- a/web/routers/ops.py +++ b/web/routers/ops.py @@ -7,6 +7,7 @@ from web.services.ops_api import ( extend_ops_subscription, get_ops_analytics_funnel, get_ops_config, + get_ops_memberships_growth, get_ops_health_check, get_ops_logs, get_ops_truth_history, @@ -38,6 +39,11 @@ async def ops_memberships(request: Request, limit: int = 200): return list_ops_memberships(request, limit=limit) +@router.get("/api/ops/memberships/growth") +async def ops_memberships_growth(request: Request, days: int = 90): + return get_ops_memberships_growth(request, days=days) + + @router.get("/api/ops/payments/incidents") async def ops_payment_incidents( request: Request, diff --git a/web/services/ops_api.py b/web/services/ops_api.py index e0ca3e09..ae5404c4 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -106,6 +106,55 @@ def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]: return {"memberships": rows} +def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, Any]: + _require_ops(request) + from collections import defaultdict + from datetime import datetime, timedelta + + safe_days = max(7, min(365, int(days or 90))) + subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(limit=5000) + now = datetime.utcnow() + cutoff = now - timedelta(days=safe_days) + + trial_by_day: dict[str, int] = defaultdict(int) + paid_by_day: dict[str, int] = defaultdict(int) + running = 0 + + for item in subscriptions: + starts_raw = str(item.get("starts_at") or "").strip() + if not starts_raw: + continue + try: + dt = datetime.fromisoformat(starts_raw.replace("Z", "+00:00")) + if dt.tzinfo is not None: + dt = dt.replace(tzinfo=None) + except Exception: + continue + if dt < cutoff: + continue + day_key = dt.strftime("%Y-%m-%d") + source = str(item.get("source") or "").strip().lower() + plan = str(item.get("plan_code") or "").strip().lower() + is_trial = source == "signup_trial" or plan.startswith("signup_trial") + if is_trial: + trial_by_day[day_key] += 1 + else: + paid_by_day[day_key] += 1 + + daily = [] + cursor = cutoff.date() + while cursor <= now.date(): + key = cursor.isoformat() + tc = trial_by_day.get(key, 0) + pc = paid_by_day.get(key, 0) + total = tc + pc + running += total + daily.append({"date": key, "trial": tc, "paid": pc, "total": total, "cumulative": running}) + cursor += timedelta(days=1) + + return {"days": safe_days, "daily": daily} + + def list_ops_payment_incidents( request: Request, limit: int = 50,