后台会员页新增增长趋势图表:累计曲线、每日新增堆叠面积图、统计卡片
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user