后台会员页新增增长趋势图表:累计曲线、每日新增堆叠面积图、统计卡片

This commit is contained in:
2569718930@qq.com
2026-05-19 19:28:38 +08:00
parent d50ced1b8f
commit e1e000e854
4 changed files with 159 additions and 14 deletions
@@ -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" }); }
}
@@ -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<MembershipEntry[]>([]);
const [growth, setGrowth] = useState<GrowthPoint[]>([]);
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() {
</h1>
<div className="flex gap-2">
{(["all", "paid", "trial"] as const).map((f) => (
<Button
key={f}
variant={filter === f ? "default" : "outline"}
size="sm"
onClick={() => setFilter(f)}
>
<Button key={f} variant={filter === f ? "default" : "outline"} size="sm" onClick={() => setFilter(f)}>
{f === "all" ? "全部" : f === "paid" ? "付费" : "体验"}
</Button>
))}
@@ -63,6 +69,75 @@ export function MembershipsPageClient() {
</Button>
</div>
</div>
{/* Growth chart */}
{growth.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"> {growth.length} </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-lg font-bold text-white">
{growth.reduce((s, d) => s + d.total, 0)}
</div>
<div className="text-[11px] text-slate-500"></div>
</div>
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-lg font-bold text-emerald-400">
{growth[growth.length - 1]?.cumulative ?? 0}
</div>
<div className="text-[11px] text-slate-500"></div>
</div>
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-lg font-bold text-cyan-400">
{(growth.reduce((s, d) => s + d.total, 0) / Math.max(1, growth.filter(d => d.total > 0).length)).toFixed(1)}
</div>
<div className="text-[11px] text-slate-500"></div>
</div>
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-lg font-bold text-amber-400">
{Math.max(...growth.map(d => d.total), 0)}
</div>
<div className="text-[11px] text-slate-500"></div>
</div>
</div>
{/* Cumulative area chart */}
<div className="mb-6">
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={growth}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={45} />
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fill="#06b6d420" name="累计" />
</AreaChart>
</ResponsiveContainer>
</div>
{/* Daily bars */}
<div>
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={growth}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={30} />
<Tooltip contentStyle={{ background: "#1e293b", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, color: "#e2e8f0", fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" name="付费" />
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b60" name="体验" />
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)}
{/* Table */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
@@ -82,13 +157,9 @@ export function MembershipsPageClient() {
<tr key={m.user_id || i} className="border-b border-white/5">
<td className="py-2.5 px-4">
{m.is_trial ? (
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] font-medium text-amber-400">
</span>
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] font-medium text-amber-400"></span>
) : (
<span className="inline-flex items-center rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-medium text-emerald-400">
</span>
<span className="inline-flex items-center rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-medium text-emerald-400"></span>
)}
</td>
<td className="py-2.5 px-4 text-white">{m.email ?? "—"}</td>
+6
View File
@@ -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,
+49
View File
@@ -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,