后台会员页新增增长趋势图表:累计曲线、每日新增堆叠面积图、统计卡片
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 { Button } from "@/components/ui/button";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
import type { MembershipEntry } from "@/types/ops";
|
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() {
|
export function MembershipsPageClient() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
|
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
|
||||||
|
const [growth, setGrowth] = useState<GrowthPoint[]>([]);
|
||||||
const [filter, setFilter] = useState<"all" | "paid" | "trial">("all");
|
const [filter, setFilter] = useState<"all" | "paid" | "trial">("all");
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await opsApi.memberships();
|
const [mData, gData] = await Promise.all([
|
||||||
setMemberships((data as unknown as { memberships?: MembershipEntry[] }).memberships ?? []);
|
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 { /* */ }
|
} catch { /* */ }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -49,12 +60,7 @@ export function MembershipsPageClient() {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(["all", "paid", "trial"] as const).map((f) => (
|
{(["all", "paid", "trial"] as const).map((f) => (
|
||||||
<Button
|
<Button key={f} variant={filter === f ? "default" : "outline"} size="sm" onClick={() => setFilter(f)}>
|
||||||
key={f}
|
|
||||||
variant={filter === f ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setFilter(f)}
|
|
||||||
>
|
|
||||||
{f === "all" ? "全部" : f === "paid" ? "付费" : "体验"}
|
{f === "all" ? "全部" : f === "paid" ? "付费" : "体验"}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@@ -63,6 +69,75 @@ export function MembershipsPageClient() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -82,13 +157,9 @@ export function MembershipsPageClient() {
|
|||||||
<tr key={m.user_id || i} className="border-b border-white/5">
|
<tr key={m.user_id || i} className="border-b border-white/5">
|
||||||
<td className="py-2.5 px-4">
|
<td className="py-2.5 px-4">
|
||||||
{m.is_trial ? (
|
{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 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>
|
|
||||||
) : (
|
) : (
|
||||||
<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 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>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 px-4 text-white">{m.email ?? "—"}</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,
|
extend_ops_subscription,
|
||||||
get_ops_analytics_funnel,
|
get_ops_analytics_funnel,
|
||||||
get_ops_config,
|
get_ops_config,
|
||||||
|
get_ops_memberships_growth,
|
||||||
get_ops_health_check,
|
get_ops_health_check,
|
||||||
get_ops_logs,
|
get_ops_logs,
|
||||||
get_ops_truth_history,
|
get_ops_truth_history,
|
||||||
@@ -38,6 +39,11 @@ async def ops_memberships(request: Request, limit: int = 200):
|
|||||||
return list_ops_memberships(request, limit=limit)
|
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")
|
@router.get("/api/ops/payments/incidents")
|
||||||
async def ops_payment_incidents(
|
async def ops_payment_incidents(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -106,6 +106,55 @@ def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]:
|
|||||||
return {"memberships": rows}
|
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(
|
def list_ops_payment_incidents(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
|||||||
Reference in New Issue
Block a user