diff --git a/frontend/components/ops/overview/OverviewPageClient.tsx b/frontend/components/ops/overview/OverviewPageClient.tsx index 12bfa1c6..a8034061 100644 --- a/frontend/components/ops/overview/OverviewPageClient.tsx +++ b/frontend/components/ops/overview/OverviewPageClient.tsx @@ -1,20 +1,41 @@ "use client"; import { useEffect, useState } from "react"; -import { RefreshCcw, TrendingUp, Users, CreditCard, Database, Activity } from "lucide-react"; -import { Card, CardContent } from "@/components/ui/card"; +import { RefreshCcw, TrendingUp, Users, CreditCard, Database, Activity, Cpu } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { opsApi } from "@/lib/ops-api"; import Link from "next/link"; -import type { SystemStatusPayload, MembershipsPayload, FunnelPayload } from "@/types/ops"; +import type { SystemStatusPayload, MembershipsPayload, FunnelPayload, MembershipEntry } from "@/types/ops"; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, + BarChart, Bar, XAxis, YAxis, CartesianGrid, } from "recharts"; +function KpiCard({ href, icon: Icon, label, value, color, sub }: { + href: string; icon: React.ElementType; label: string; value: string | number; color: string; sub?: string; +}) { + return ( + + + +
+ + {label} +
+
{value}
+ {sub ?
{sub}
: null} +
+
+ + ); +} + export function OverviewPageClient() { const [loading, setLoading] = useState(true); const [status, setStatus] = useState(null); - const [memberships, setMemberships] = useState(null); + const [memberships, setMemberships] = useState([]); const [funnel, setFunnel] = useState(null); const load = async () => { @@ -23,10 +44,10 @@ export function OverviewPageClient() { const [s, m, f] = await Promise.all([ opsApi.systemStatus() as Promise, opsApi.memberships() as Promise, - opsApi.funnel(7) as Promise, + opsApi.funnel(30) as Promise, ]); setStatus(s); - setMemberships(m); + setMemberships((m as MembershipsPayload).memberships ?? []); setFunnel(f); } catch { /* */ } setLoading(false); @@ -34,142 +55,273 @@ export function OverviewPageClient() { useEffect(() => { void load(); }, []); - if (loading) return
加载中...
; + if (loading) return ( +
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+ ); - const cacheAnalysis = status?.cache?.analysis; - const cacheData = cacheAnalysis ? [ + // ── Derived data ── + const paid = memberships.filter((m) => !m.is_trial).length; + const trials = memberships.filter((m) => m.is_trial).length; + const steps = funnel?.steps ?? []; + const totalUsers = steps[0]?.count ?? 0; + const payingUsers = steps[5]?.count ?? 0; + const convRate = totalUsers > 0 ? ((payingUsers / totalUsers) * 100).toFixed(1) : "—"; + const cache = status?.cache; + const cacheAnalysis = cache?.analysis; + const td = status?.training_data; + const features = status?.features; + + // Cache bucket data for bar chart + const cacheBuckets = cache ? [ + { name: "API", value: cache.api_cache_entries ?? 0 }, + { name: "预报", value: cache.open_meteo_forecast_entries ?? 0 }, + { name: "METAR", value: cache.metar_entries ?? 0 }, + { name: "TAF", value: cache.taf_entries ?? 0 }, + { name: "结算", value: cache.settlement_entries ?? 0 }, + ].filter((d) => d.value > 0) : []; + + // Cache pie + const cachePie = cacheAnalysis ? [ { name: "命中", value: cacheAnalysis.cache_hits ?? 0, color: "#22c55e" }, { name: "未命中", value: cacheAnalysis.cache_misses ?? 0, color: "#f59e0b" }, { name: "强制刷新", value: cacheAnalysis.force_refresh_requests ?? 0, color: "#3b82f6" }, ].filter((d) => d.value > 0) : []; - const mems = memberships?.memberships ?? []; - const paid = mems.filter((m) => !(m as Record).is_trial).length; - const trials = mems.filter((m) => (m as Record).is_trial).length; + // Membership pie + const memberPie = [ + { name: "付费", value: paid, color: "#22c55e" }, + ...(trials > 0 ? [{ name: "体验", value: trials, color: "#f59e0b" }] : []), + ]; - const steps = funnel?.steps ?? []; - const totalUsers = steps[0]?.count ?? 0; - const payingUsers = steps[5]?.count ?? 0; - const convRate = totalUsers > 0 ? ((payingUsers / totalUsers) * 100).toFixed(1) : "—"; + // Plan breakdown + const planCounts: Record = {}; + memberships.forEach((m) => { + const code = m.plan_code ?? "unknown"; + planCounts[code] = (planCounts[code] ?? 0) + 1; + }); + const planBreakdown = Object.entries(planCounts) + .map(([k, v]) => { + const label = k.startsWith("signup_trial") ? "3天体验" : k === "pro_monthly" ? "月付" : k === "pro_quarterly" ? "季付" : k === "pro_yearly" ? "年付" : k; + return { name: label, value: v }; + }) + .sort((a, b) => b.value - a.value); + + const truthRows = td?.truth_records?.row_count ?? 0; + const coverage = td?.city_coverage; return ( -
+
-

总览

+
+

总览

+

系统实时数据快照

+
- {/* KPI cards */} -
- - - -
- - 系统状态 -
-
{status?.db?.ok ? "OK" : "—"}
-
-
- - - - -
- - 付费会员 -
-
{paid}
- {trials > 0 &&
体验 {trials}
} -
-
- - - - -
- - 支付成功 -
-
{payingUsers}
-
-
- - - - -
- - 转化率 -
-
{convRate}%
-
-
- - - - -
- - 训练数据 -
-
- {status?.training_data?.truth_records?.row_count ?? "—"} -
-
-
- + {/* KPI row */} +
+ + 0 ? `+${trials} 体验` : undefined} /> + + + +
- {/* Cache pie chart */} - {cacheData.length > 0 && ( - - -

缓存命中率

-
-
- - - - {cacheData.map((d, i) => ( - - ))} - - - + {/* Main grid: 2 columns */} +
+ + {/* Column 1 */} +
+ + {/* Funnel mini chart */} + {steps.length > 0 && ( + + + 30天转化漏斗 + + + + ({ name: s.label, count: s.count }))} layout="vertical" margin={{ left: 80, right: 20 }}> + + + + + + + + + )} + + {/* Cache buckets */} + {cacheBuckets.length > 0 && ( + + + 缓存桶分布 + + + + + + + + + + + + + + )} + + {/* Features */} + {features && ( + + + 功能开关 + + +
+ {Object.entries(features).map(([k, v]) => ( + + {k.replace(/_/g, " ")}: {String(v)} + + ))} +
+
+
+ )} +
+ + {/* Column 2 */} +
+ + {/* Membership donut + plan breakdown side by side */} + + + 会员分布 + + +
+
+ + + + {memberPie.map((d, i) => ())} + + + + +
+
+ {memberPie.map((d) => ( +
+ + {d.name} + {d.value} +
+ ))} + {planBreakdown.length > 0 && ( +
+ {planBreakdown.map((p) => ( +
+ {p.name} + {p.value} +
+ ))} +
+ )} +
-
- {cacheData.map((d) => ( -
- - {d.name} - {d.value} + + + + {/* Cache hit rate donut */} + {cachePie.length > 0 && ( + + + + 缓存分析{" "} + {cacheAnalysis?.hit_rate != null && ( + {(cacheAnalysis.hit_rate * 100).toFixed(1)}% 命中 + )} + + + +
+
+ + + + {cachePie.map((d, i) => ())} + + + +
- ))} - {cacheAnalysis?.hit_rate != null && ( -
- 命中率 - {(cacheAnalysis.hit_rate * 100).toFixed(1)}% +
+
+
总请求
+
{cacheAnalysis?.total_requests ?? 0}
+
+
+
强制刷新
+
{cacheAnalysis?.force_refresh_requests ?? 0}
+
+
+
命中
+
{cacheAnalysis?.cache_hits ?? 0}
+
+
+
未命中
+
{cacheAnalysis?.cache_misses ?? 0}
+
- )} -
-
-
-
- )} +
+ + + )} + + {/* Training data summary */} + {coverage && ( + + + 城市模型覆盖 + + +
+
+
{coverage.total_cities ?? 0}
+
总城市
+
+
+
{coverage.with_truth_rows ?? 0}
+
有真值
+
+
+
{coverage.with_emos_samples ?? 0}
+
有EMOS
+
+
+
{coverage.with_lgbm_samples ?? 0}
+
有LGBM
+
+
+
+
+ )} +
+
); }