diff --git a/frontend/app/ops/analytics/page.tsx b/frontend/app/ops/analytics/page.tsx new file mode 100644 index 00000000..3bbdd211 --- /dev/null +++ b/frontend/app/ops/analytics/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { AnalyticsPageClient } from "@/components/ops/analytics/AnalyticsPageClient"; + +export const metadata: Metadata = { title: "转化分析 — PolyWeather Ops" }; + +export default async function AnalyticsPage() { + await requireOpsAdmin("/ops/analytics"); + return ; +} diff --git a/frontend/app/ops/config/page.tsx b/frontend/app/ops/config/page.tsx new file mode 100644 index 00000000..67be5b48 --- /dev/null +++ b/frontend/app/ops/config/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { ConfigPageClient } from "@/components/ops/config/ConfigPageClient"; + +export const metadata: Metadata = { title: "系统配置 — PolyWeather Ops" }; + +export default async function ConfigPage() { + await requireOpsAdmin("/ops/config"); + return ; +} diff --git a/frontend/app/ops/layout.tsx b/frontend/app/ops/layout.tsx new file mode 100644 index 00000000..fcbdc1ce --- /dev/null +++ b/frontend/app/ops/layout.tsx @@ -0,0 +1,5 @@ +import { AdminShell } from "@/components/ops/layout/AdminShell"; + +export default function OpsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/frontend/app/ops/memberships/page.tsx b/frontend/app/ops/memberships/page.tsx new file mode 100644 index 00000000..711198fb --- /dev/null +++ b/frontend/app/ops/memberships/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { MembershipsPageClient } from "@/components/ops/memberships/MembershipsPageClient"; + +export const metadata: Metadata = { title: "会员订阅 — PolyWeather Ops" }; + +export default async function MembershipsPage() { + await requireOpsAdmin("/ops/memberships"); + return ; +} diff --git a/frontend/app/ops/page.tsx b/frontend/app/ops/page.tsx index 80553165..6f91d6da 100644 --- a/frontend/app/ops/page.tsx +++ b/frontend/app/ops/page.tsx @@ -1,13 +1,5 @@ -import type { Metadata } from "next"; -import { OpsDashboard } from "@/components/ops/OpsDashboard"; -import { requireOpsAdmin } from "@/lib/ops-admin"; +import { redirect } from "next/navigation"; -export const metadata: Metadata = { - title: "PolyWeather Ops", - description: "PolyWeather lightweight operations dashboard.", -}; - -export default async function OpsPage() { - await requireOpsAdmin("/ops"); - return ; +export default function OpsPage() { + redirect("/ops/system"); } diff --git a/frontend/app/ops/payments/page.tsx b/frontend/app/ops/payments/page.tsx new file mode 100644 index 00000000..b067e530 --- /dev/null +++ b/frontend/app/ops/payments/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { PaymentsPageClient } from "@/components/ops/payments/PaymentsPageClient"; + +export const metadata: Metadata = { title: "支付管理 — PolyWeather Ops" }; + +export default async function PaymentsPage() { + await requireOpsAdmin("/ops/payments"); + return ; +} diff --git a/frontend/app/ops/subscriptions/page.tsx b/frontend/app/ops/subscriptions/page.tsx new file mode 100644 index 00000000..ac97a661 --- /dev/null +++ b/frontend/app/ops/subscriptions/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { SubscriptionsPageClient } from "@/components/ops/subscriptions/SubscriptionsPageClient"; + +export const metadata: Metadata = { title: "订阅操作 — PolyWeather Ops" }; + +export default async function SubscriptionsPage() { + await requireOpsAdmin("/ops/subscriptions"); + return ; +} diff --git a/frontend/app/ops/system/page.tsx b/frontend/app/ops/system/page.tsx new file mode 100644 index 00000000..90042ce7 --- /dev/null +++ b/frontend/app/ops/system/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { SystemPageClient } from "@/components/ops/system/SystemPageClient"; + +export const metadata: Metadata = { + title: "系统状态 — PolyWeather Ops", +}; + +export default async function SystemPage() { + const user = await requireOpsAdmin("/ops/system"); + return ; +} diff --git a/frontend/app/ops/training/page.tsx b/frontend/app/ops/training/page.tsx new file mode 100644 index 00000000..8d584d32 --- /dev/null +++ b/frontend/app/ops/training/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { TrainingPageClient } from "@/components/ops/training/TrainingPageClient"; + +export const metadata: Metadata = { title: "训练数据 — PolyWeather Ops" }; + +export default async function TrainingPage() { + await requireOpsAdmin("/ops/training"); + return ; +} diff --git a/frontend/app/ops/users/page.tsx b/frontend/app/ops/users/page.tsx new file mode 100644 index 00000000..1a458078 --- /dev/null +++ b/frontend/app/ops/users/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { UsersPageClient } from "@/components/ops/users/UsersPageClient"; + +export const metadata: Metadata = { title: "用户积分 — PolyWeather Ops" }; + +export default async function UsersPage() { + await requireOpsAdmin("/ops/users"); + return ; +} diff --git a/frontend/components/ops/analytics/AnalyticsPageClient.tsx b/frontend/components/ops/analytics/AnalyticsPageClient.tsx new file mode 100644 index 00000000..59d65950 --- /dev/null +++ b/frontend/components/ops/analytics/AnalyticsPageClient.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCcw } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { opsApi } from "@/lib/ops-api"; + +type FunnelStep = { label: string; count: number; pct_of_prev?: number }; + +export function AnalyticsPageClient() { + const [loading, setLoading] = useState(true); + const [funnel, setFunnel] = useState([]); + const [days, setDays] = useState(30); + + const load = async () => { + setLoading(true); + try { + const data = await opsApi.funnel(days); + const steps = (data as unknown as { steps?: FunnelStep[] }).steps ?? []; + setFunnel(steps); + } catch { /* */ } + setLoading(false); + }; + + useEffect(() => { void load(); }, [days]); + + const maxCount = Math.max(...funnel.map((s) => s.count), 1); + + if (loading) return
加载中...
; + + return ( +
+
+

转化分析

+
+ {[7, 14, 30].map((d) => ( + + ))} + +
+
+ + + 用户转化漏斗 + +
+ {funnel.map((step, i) => { + const pct = (step.count / maxCount) * 100; + const prevPct = step.pct_of_prev != null ? `${step.pct_of_prev}%` : "—"; + return ( +
+
+ {step.label} + + {step.count} + 转化 {prevPct} + +
+
+
+
+
+ ); + })} + {funnel.length === 0 && ( +

暂无数据

+ )} +
+ + +
+ ); +} diff --git a/frontend/components/ops/config/ConfigPageClient.tsx b/frontend/components/ops/config/ConfigPageClient.tsx new file mode 100644 index 00000000..c4dfcee0 --- /dev/null +++ b/frontend/components/ops/config/ConfigPageClient.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCcw, Save } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +type EditableConfig = { + key: string; + value: string; + description: string; +}; + +export function ConfigPageClient() { + const [configs, setConfigs] = useState([]); + const [editing, setEditing] = useState>({}); + const [saving, setSaving] = useState(false); + const [result, setResult] = useState(""); + + const load = async () => { + try { + const res = await fetch("/api/ops/config"); + if (res.ok) { + const data = (await res.json()) as { configs?: EditableConfig[] }; + setConfigs(data.configs ?? []); + } + } catch { /* backend not ready yet */ } + }; + + const handleSave = async (key: string) => { + const newVal = editing[key]; + if (newVal == null) return; + setSaving(true); + setResult(""); + try { + const res = await fetch("/api/ops/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, value: newVal }), + }); + if (res.ok) { + setResult(`${key} 已更新`); + setConfigs((prev) => prev.map((c) => (c.key === key ? { ...c, value: newVal } : c))); + setEditing((prev) => { const n = { ...prev }; delete n[key]; return n; }); + } else { + setResult(`保存失败: ${await res.text().catch(() => "")}`); + } + } catch { + setResult("保存失败"); + } + setSaving(false); + }; + + useEffect(() => { void load(); }, []); + + return ( +
+
+

系统配置

+ +
+ + + + 可编辑配置 + + + {configs.length === 0 ? ( +

配置 API 尚未就绪(需要后端支持)

+ ) : ( +
+ {configs.map((cfg) => ( +
+
+
{cfg.key}
+
{cfg.description}
+
+ setEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))} + className="w-24 rounded-lg border border-white/10 bg-black/30 px-3 py-1.5 text-sm text-white font-mono text-center outline-none focus:border-cyan-400/50" + /> + +
+ ))} +
+ )} + {result && ( +

+ {result} +

+ )} +

+ 仅显示非敏感配置项。API Key 等密钥不在此处暴露。修改后立即生效,建议重启服务以确保持久化。 +

+
+
+
+ ); +} diff --git a/frontend/components/ops/layout/AdminShell.tsx b/frontend/components/ops/layout/AdminShell.tsx new file mode 100644 index 00000000..5a56b70d --- /dev/null +++ b/frontend/components/ops/layout/AdminShell.tsx @@ -0,0 +1,12 @@ +import { AdminSidebar } from "./AdminSidebar"; + +export function AdminShell({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/frontend/components/ops/layout/AdminSidebar.tsx b/frontend/components/ops/layout/AdminSidebar.tsx new file mode 100644 index 00000000..2024050f --- /dev/null +++ b/frontend/components/ops/layout/AdminSidebar.tsx @@ -0,0 +1,103 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + LayoutDashboard, + Cpu, + Database, + CreditCard, + Users, + UserCheck, + BarChart3, + Settings, + FileText, + ScrollText, + Activity, +} from "lucide-react"; + +const navGroups = [ + { + label: "监控", + items: [ + { href: "/ops/system", icon: Cpu, label: "系统状态" }, + { href: "/ops/training", icon: Database, label: "训练数据" }, + { href: "/ops/analytics", icon: BarChart3, label: "转化分析" }, + ], + }, + { + label: "运营", + items: [ + { href: "/ops/payments", icon: CreditCard, label: "支付管理" }, + { href: "/ops/memberships", icon: UserCheck, label: "会员订阅" }, + { href: "/ops/users", icon: Users, label: "用户积分" }, + ], + }, + { + label: "管理", + items: [ + { href: "/ops/config", icon: Settings, label: "系统配置" }, + { href: "/ops/subscriptions", icon: ScrollText, label: "订阅操作" }, + { href: "/ops/logs", icon: FileText, label: "日志查看" }, + ], + }, + { + label: "历史", + items: [ + { href: "/ops/truth-history", icon: Activity, label: "真值历史" }, + ], + }, +]; + +export function AdminSidebar() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/frontend/components/ops/memberships/MembershipsPageClient.tsx b/frontend/components/ops/memberships/MembershipsPageClient.tsx new file mode 100644 index 00000000..7e971ed1 --- /dev/null +++ b/frontend/components/ops/memberships/MembershipsPageClient.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCcw } from "lucide-react"; +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"; + +export function MembershipsPageClient() { + const [loading, setLoading] = useState(true); + const [memberships, setMemberships] = useState([]); + + const load = async () => { + setLoading(true); + try { + const data = await opsApi.memberships(); + setMemberships((data as unknown as { memberships?: MembershipEntry[] }).memberships ?? []); + } catch { /* */ } + setLoading(false); + }; + + useEffect(() => { void load(); }, []); + + if (loading) return
加载中...
; + + return ( +
+
+

会员订阅 ({memberships.length})

+ +
+ + +
+ + + + + + + + + + + + + {memberships.map((m, i) => ( + + + + + + + + + ))} + {memberships.length === 0 && ( + + )} + +
邮箱用户ID方案起始到期排队天数
{m.email ?? "—"}{m.user_id?.slice(0, 12) ?? "—"}{m.plan_code ?? "—"}{m.starts_at?.slice(0, 10) ?? "—"}{m.expires_at?.slice(0, 10) ?? "—"}{m.queued_days ?? 0}
暂无会员
+
+
+
+
+ ); +} diff --git a/frontend/components/ops/payments/PaymentsPageClient.tsx b/frontend/components/ops/payments/PaymentsPageClient.tsx new file mode 100644 index 00000000..8bc5297a --- /dev/null +++ b/frontend/components/ops/payments/PaymentsPageClient.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCcw, CheckCircle2 } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { opsApi } from "@/lib/ops-api"; +import type { PaymentRuntimePayload, PaymentIncident } from "@/types/ops"; + +export function PaymentsPageClient() { + const [loading, setLoading] = useState(true); + const [runtime, setRuntime] = useState(null); + const [incidents, setIncidents] = useState([]); + const [resolving, setResolving] = useState>(new Set()); + + const load = async () => { + setLoading(true); + try { + const [rt, inc] = await Promise.all([ + opsApi.paymentRuntime() as Promise, + opsApi.incidents(50), + ]); + setRuntime(rt); + setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []); + } catch { /* */ } + setLoading(false); + }; + + const handleResolve = async (id: number) => { + setResolving((prev) => new Set(prev).add(id)); + try { + await opsApi.resolveIncident(id); + setIncidents((prev) => prev.filter((i) => i.id !== id)); + } catch { /* */ } + setResolving((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }; + + useEffect(() => { void load(); }, []); + + if (loading) return
加载中...
; + + return ( +
+
+

支付管理

+ +
+ + + 支付运行时 + +
+ {runtime ? Object.entries({ + chain_id: runtime.chain_id, + last_scanned_block: runtime.last_scanned_block, + audit_events_count: runtime.audit_events_count, + }).map(([k, v]) => ( +
+
{k}
+
{String(v ?? "—")}
+
+ )) : 无数据} +
+ {runtime?.receiver_contract ? ( +
+
receiver_contract
+ {runtime.receiver_contract} +
+ ) : null} +
+
+ + + 支付异常 ({incidents.length}) + + {incidents.length === 0 ? ( + 暂无异常 + ) : ( +
+ + + + + + + + + + + {incidents.map((inc) => ( + + + + + + + ))} + +
ID原因时间操作
{inc.id}{inc.reason ?? "—"}{inc.created_at?.slice(0, 19) ?? "—"} + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/components/ops/subscriptions/SubscriptionsPageClient.tsx b/frontend/components/ops/subscriptions/SubscriptionsPageClient.tsx new file mode 100644 index 00000000..9f190310 --- /dev/null +++ b/frontend/components/ops/subscriptions/SubscriptionsPageClient.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState } from "react"; +import { ScrollText, Coins } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export function SubscriptionsPageClient() { + const [email, setEmail] = useState(""); + const [planCode, setPlanCode] = useState("pro_monthly"); + const [days, setDays] = useState(30); + const [extendDays, setExtendDays] = useState(30); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(""); + + const handleGrant = async () => { + if (!email.trim()) return; + setBusy(true); + setResult(""); + try { + const res = await fetch("/api/ops/subscriptions/grant", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim(), plan_code: planCode, days }), + }); + if (res.ok) { + setResult(`已为 ${email} 开通 ${planCode},${days} 天`); + } else { + setResult(`失败: ${await res.text().catch(() => "")}`); + } + } catch (e) { + setResult(`错误: ${String(e).slice(0, 100)}`); + } + setBusy(false); + }; + + const handleExtend = async () => { + if (!email.trim()) return; + setBusy(true); + setResult(""); + try { + const res = await fetch("/api/ops/subscriptions/extend", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim(), additional_days: extendDays }), + }); + if (res.ok) { + setResult(`已为 ${email} 延期 ${extendDays} 天`); + } else { + setResult(`失败: ${await res.text().catch(() => "")}`); + } + } catch (e) { + setResult(`错误: ${String(e).slice(0, 100)}`); + } + setBusy(false); + }; + + return ( +
+

订阅操作

+

手动为用户开通或延期订阅(需要后端 API 就绪)

+ + + 手动开通 + +
+ setEmail(e.target.value)} + placeholder="用户 Supabase 邮箱" + className="flex-1 min-w-[200px] rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder:text-slate-600 outline-none focus:border-cyan-400/50" + /> + + setDays(Math.max(1, Math.min(365, Number(e.target.value) || 30)))} + className="w-20 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50" + /> + +
+
+
+ + + 手动延期 + +
+ setEmail(e.target.value)} + placeholder="用户 Supabase 邮箱" + className="flex-1 min-w-[200px] rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder:text-slate-600 outline-none focus:border-cyan-400/50" + /> + setExtendDays(Math.max(1, Math.min(365, Number(e.target.value) || 30)))} + className="w-20 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50" + /> + + +
+ {result && ( +

+ {result} +

+ )} +
+
+
+ ); +} diff --git a/frontend/components/ops/system/SystemPageClient.tsx b/frontend/components/ops/system/SystemPageClient.tsx new file mode 100644 index 00000000..d684c69a --- /dev/null +++ b/frontend/components/ops/system/SystemPageClient.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCcw, ShieldCheck, Database, Cpu, HardDrive } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { opsApi } from "@/lib/ops-api"; +import type { SystemStatusPayload, HealthPayload } from "@/types/ops"; + +export function SystemPageClient() { + const [loading, setLoading] = useState(true); + const [health, setHealth] = useState(null); + const [status, setStatus] = useState(null); + const [error, setError] = useState(""); + + const load = async () => { + setLoading(true); + setError(""); + try { + const [h, s] = await Promise.all([ + opsApi.health(), + opsApi.systemStatus() as Promise, + ]); + setHealth(h); + setStatus(s); + } catch (e) { + setError(String(e).slice(0, 200)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + if (loading) { + return
加载中...
; + } + + if (error) { + return
加载失败: {error}
; + } + + const dbOk = status?.db?.ok ?? health?.db?.ok; + const cacheAnalysis = status?.cache?.analysis; + + return ( +
+
+

系统状态

+ +
+ + {/* Health badges */} +
+ + + +
+
Health
+
{health?.status ?? "—"}
+
+
+
+ + + +
+
Database
+
{dbOk ? "OK" : "FAIL"}
+
+
+
+ + + +
+
存储模式
+
{status?.state_storage_mode ?? "—"}
+
+
+
+ + + +
+
概率引擎
+
{status?.probability?.engine_mode ?? "—"}
+
+
+
+
+ + {/* Features & Integrations */} +
+ + + 功能开关 + + +
+ {status?.features + ? Object.entries(status.features).map(([k, v]) => ( +
+ {k} + {String(v)} +
+ )) + : 无数据} +
+
+
+ + + + 集成状态 + + +
+ {status?.integrations + ? Object.entries(status.integrations).map(([k, v]) => ( +
+ {k} + {String(v)} +
+ )) + : 无数据} +
+
+
+
+ + {/* Cache Analysis */} + {cacheAnalysis ? ( + + + 缓存分析 + + +
+
+
总请求
+
{cacheAnalysis.total_requests ?? 0}
+
+
+
命中
+
{cacheAnalysis.cache_hits ?? 0}
+
+
+
未命中
+
{cacheAnalysis.cache_misses ?? 0}
+
+
+
命中率
+
+ {cacheAnalysis.hit_rate != null ? `${(cacheAnalysis.hit_rate * 100).toFixed(0)}%` : "—"} +
+
+
+
+
+ ) : null} + + {/* DB Path */} + {status?.db?.db_path ? ( + + + 数据库路径 + + + + {status.db.db_path} + + + + ) : null} +
+ ); +} diff --git a/frontend/components/ops/training/TrainingPageClient.tsx b/frontend/components/ops/training/TrainingPageClient.tsx new file mode 100644 index 00000000..78e3713c --- /dev/null +++ b/frontend/components/ops/training/TrainingPageClient.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCcw } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { opsApi } from "@/lib/ops-api"; +import type { SystemStatusPayload } from "@/types/ops"; +import Link from "next/link"; + +function StatRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function TrainingPageClient() { + const [loading, setLoading] = useState(true); + const [status, setStatus] = useState(null); + + const load = async () => { + setLoading(true); + try { + const s = await opsApi.systemStatus() as SystemStatusPayload; + setStatus(s); + } catch { /* */ } + setLoading(false); + }; + + useEffect(() => { void load(); }, []); + + if (loading) return
加载中...
; + if (!status) return
加载失败
; + + const td = status.training_data; + const truth = td?.truth_records; + const features = td?.training_features; + const coverage = td?.city_coverage; + const emos = td?.emos_samples as Record | undefined; + const lgbm = td?.lgbm_samples as Record | undefined; + const modelCities = td?.model_cities; + + return ( +
+
+

训练数据

+ +
+ +
+ + 真值记录 + + + + + + + + 训练特征 + + + + + + + + 城市覆盖 + + + + + + + + +
+ +
+ + EMOS 样本 + + {emos ? ( +
+ {Object.entries(emos).map(([k, v]) => ( + + ))} +
+ ) : 无数据} +
+
+ + LGBM 样本 + + {lgbm ? ( +
+ {Object.entries(lgbm).map(([k, v]) => ( + + ))} +
+ ) : 无数据} +
+
+
+ + {modelCities ? ( +
+ + 最强城市 + + {modelCities.strongest?.length ? ( +
    + {modelCities.strongest.map((c, i) => ( +
  • + {c.city} + EMOS:{c.emos ?? "—"} LGBM:{c.lgbm ?? "—"} +
  • + ))} +
+ ) : 无数据} +
+
+ + 覆盖缺口 + + {modelCities.gaps?.length ? ( +
+ {modelCities.gaps.map((c) => ( + {c} + ))} +
+ ) : 无缺口} +
+
+
+ ) : null} + + + + 真值历史浏览 + + +

按城市和日期筛选查看历史真值记录。

+ + 打开真值历史 → + +
+
+
+ ); +} diff --git a/frontend/components/ops/users/UsersPageClient.tsx b/frontend/components/ops/users/UsersPageClient.tsx new file mode 100644 index 00000000..32665636 --- /dev/null +++ b/frontend/components/ops/users/UsersPageClient.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { RefreshCcw, Search, Coins } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { opsApi } from "@/lib/ops-api"; +import type { OpsUser, LeaderboardEntry } from "@/types/ops"; + +export function UsersPageClient() { + const [query, setQuery] = useState(""); + const [searching, setSearching] = useState(false); + const [users, setUsers] = useState([]); + const [leaderboard, setLeaderboard] = useState([]); + const [grantEmail, setGrantEmail] = useState(""); + const [grantPoints, setGrantPoints] = useState(100); + const [grantResult, setGrantResult] = useState(""); + const [grantBusy, setGrantBusy] = useState(false); + + const search = useCallback(async () => { + if (!query.trim()) return; + setSearching(true); + try { + const data = await opsApi.users(query.trim()); + setUsers((data as unknown as { users?: OpsUser[] }).users ?? []); + } catch { /* */ } + setSearching(false); + }, [query]); + + const loadLeaderboard = async () => { + try { + const data = await opsApi.leaderboard(); + setLeaderboard((data as unknown as { leaderboard?: LeaderboardEntry[] }).leaderboard ?? []); + } catch { /* */ } + }; + + const handleGrant = async () => { + if (!grantEmail.trim() || grantPoints <= 0) return; + setGrantBusy(true); + setGrantResult(""); + try { + const data = await opsApi.grantPoints(grantEmail.trim(), grantPoints); + const result = data as unknown as { ok?: boolean; points_added?: number; points_after?: number; reason?: string }; + if (result.ok) { + setGrantResult(`成功: +${result.points_added} 分, 当前 ${result.points_after} 分`); + } else { + setGrantResult(`失败: ${result.reason ?? "unknown"}`); + } + } catch (e) { + setGrantResult(`错误: ${String(e).slice(0, 100)}`); + } + setGrantBusy(false); + }; + + useEffect(() => { + void loadLeaderboard(); + }, []); + + return ( +
+

用户积分

+ + {/* Search */} + + 用户搜索 + +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && search()} + placeholder="Telegram ID / 用户名 / 邮箱" + className="flex-1 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder:text-slate-600 outline-none focus:border-cyan-400/50" + /> + +
+ {users.length > 0 && ( +
+ {users.map((u, i) => ( +
+
+ {u.username || `TG${u.telegram_id}`} + {u.supabase_email || ""} +
+
+ {u.points ?? 0} 积分 + {u.message_count ?? 0} 发言 + 周 {u.weekly_points ?? 0} +
+
+ ))} +
+ )} +
+
+ + {/* Grant Points */} + + 积分运营 + +
+ setGrantEmail(e.target.value)} + placeholder="用户 Supabase 邮箱" + className="flex-1 min-w-[200px] rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder:text-slate-600 outline-none focus:border-cyan-400/50" + /> + setGrantPoints(Math.max(1, Math.min(100000, Number(e.target.value) || 0)))} + className="w-24 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50" + /> + +
+ {grantResult && ( +

+ {grantResult} +

+ )} +
+
+ + {/* Leaderboard */} + + 本周排行榜 Top {leaderboard.length} + + {leaderboard.length === 0 ? ( + 无数据 + ) : ( +
    + {leaderboard.map((entry, i) => ( +
  1. + + #{entry.rank ?? i + 1} + {entry.username ?? `TG${entry.telegram_id}`} + + {entry.weekly_points ?? 0} 分 +
  2. + ))} +
+ )} + +
+
+
+ ); +} diff --git a/frontend/lib/ops-api.ts b/frontend/lib/ops-api.ts new file mode 100644 index 00000000..11c8cb15 --- /dev/null +++ b/frontend/lib/ops-api.ts @@ -0,0 +1,55 @@ +type FetchOptions = RequestInit & { timeoutMs?: number }; + +async function opsFetch(url: string, options?: FetchOptions): Promise { + const res = await fetch(url, { cache: "no-store", ...options }); + if (!res.ok) { + const detail = (await res.text().catch(() => "")).slice(0, 300); + throw new Error(`${res.status} ${res.statusText}: ${detail}`); + } + return res.json() as Promise; +} + +export const opsApi = { + health() { + return opsFetch<{ status: string }>("/api/healthz"); + }, + systemStatus() { + return opsFetch>("/api/system/status"); + }, + paymentRuntime() { + return opsFetch>("/api/payments/runtime"); + }, + funnel(days = 30) { + return opsFetch>(`/api/ops/analytics/funnel?days=${days}`); + }, + users(q: string, limit = 20) { + return opsFetch>(`/api/ops/users?q=${encodeURIComponent(q)}&limit=${limit}`); + }, + grantPoints(email: string, points: number) { + return opsFetch>("/api/ops/users/grant-points", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, points }), + }); + }, + leaderboard(limit = 10) { + return opsFetch>(`/api/ops/leaderboard/weekly?limit=${limit}`); + }, + memberships() { + return opsFetch>("/api/ops/memberships?limit=200"); + }, + incidents(limit = 20, reason?: string) { + const params = new URLSearchParams({ limit: String(limit) }); + if (reason) params.set("reason", reason); + return opsFetch>(`/api/ops/payments/incidents?${params}`); + }, + resolveIncident(eventId: string | number) { + return opsFetch>(`/api/ops/payments/incidents/${eventId}/resolve`, { + method: "POST", + }); + }, + truthHistory(params: Record) { + const qs = new URLSearchParams(params).toString(); + return opsFetch>(`/api/ops/truth-history?${qs}`); + }, +}; diff --git a/frontend/types/ops.ts b/frontend/types/ops.ts new file mode 100644 index 00000000..832371f6 --- /dev/null +++ b/frontend/types/ops.ts @@ -0,0 +1,148 @@ +export type HealthPayload = { + status?: string; + db?: { ok?: boolean }; +}; + +export type SystemStatusPayload = { + state_storage_mode?: string; + db?: { ok?: boolean; db_path?: string }; + features?: Record; + cache?: { + api_cache_entries?: number; + open_meteo_forecast_entries?: number; + metar_entries?: number; + taf_entries?: number; + settlement_entries?: number; + analysis?: { + total_requests?: number; + cache_hits?: number; + cache_misses?: number; + force_refresh_requests?: number; + hit_rate?: number | null; + }; + }; + probability?: { + engine_mode?: string; + rollout?: Record; + }; + integrations?: Record; + training_data?: { + truth_records?: { + row_count?: number; + cities_count?: number; + min_date?: string | null; + max_date?: string | null; + source_counts?: Record; + }; + training_features?: { + row_count?: number; + cities_count?: number; + min_date?: string | null; + max_date?: string | null; + }; + city_coverage?: { + total_cities?: number; + with_truth_rows?: number; + with_feature_rows?: number; + with_emos_samples?: number; + with_lgbm_samples?: number; + }; + emos_samples?: Record; + lgbm_samples?: Record; + model_cities?: { + strongest?: Array<{ city: string; emos?: number; lgbm?: number }>; + gaps?: string[]; + }; + }; +}; + +export type PaymentRuntimePayload = { + rpc?: string; + chain_id?: number; + receiver_contract?: string; + last_scanned_block?: number; + audit_events_count?: number; + recent_events?: Array>; +}; + +export type PaymentIncident = { + id: number; + event_type?: string; + reason?: string; + payload_json?: string; + created_at?: string; + resolved?: boolean; +}; + +export type IncidentsPayload = { + incidents?: PaymentIncident[]; + total?: number; +}; + +export type OpsUser = { + telegram_id?: number; + username?: string; + supabase_email?: string; + points?: number; + weekly_points?: number; + message_count?: number; +}; + +export type UsersPayload = { + users?: OpsUser[]; +}; + +export type GrantPointsResult = { + ok?: boolean; + points_added?: number; + points_after?: number; + reason?: string; +}; + +export type MembershipEntry = { + email?: string; + username?: string; + user_id?: string; + starts_at?: string; + expires_at?: string; + total_expires_at?: string; + queued_days?: number; + queued_count?: number; + plan_code?: string; +}; + +export type MembershipsPayload = { + memberships?: MembershipEntry[]; + total?: number; +}; + +export type LeaderboardEntry = { + telegram_id?: number; + username?: string; + weekly_points?: number; + rank?: number; +}; + +export type LeaderboardPayload = { + leaderboard?: LeaderboardEntry[]; +}; + +export type FunnelPayload = { + steps?: Array<{ + label: string; + count: number; + pct_of_prev?: number; + }>; + period_days?: number; +}; + +export type TruthHistoryPayload = { + rows?: Array>; + total?: number; +}; + +export type ConfigEntry = { + key: string; + value: string; + description: string; +};