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 + + + + + )} + + ); }
系统实时数据快照