后台管理系统基础框架:侧边栏布局、9 个页面模块、共享类型和 API 客户端

This commit is contained in:
2569718930@qq.com
2026-05-18 20:10:08 +08:00
parent 2d1cb55151
commit 073d0efbb0
22 changed files with 1416 additions and 11 deletions
+10
View File
@@ -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 <AnalyticsPageClient />;
}
+10
View File
@@ -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 <ConfigPageClient />;
}
+5
View File
@@ -0,0 +1,5 @@
import { AdminShell } from "@/components/ops/layout/AdminShell";
export default function OpsLayout({ children }: { children: React.ReactNode }) {
return <AdminShell>{children}</AdminShell>;
}
+10
View File
@@ -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 <MembershipsPageClient />;
}
+3 -11
View File
@@ -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 <OpsDashboard />;
export default function OpsPage() {
redirect("/ops/system");
}
+10
View File
@@ -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 <PaymentsPageClient />;
}
+10
View File
@@ -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 <SubscriptionsPageClient />;
}
+12
View File
@@ -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 <SystemPageClient />;
}
+10
View File
@@ -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 <TrainingPageClient />;
}
+10
View File
@@ -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 <UsersPageClient />;
}
@@ -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<FunnelStep[]>([]);
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 <div className="text-slate-400 animate-pulse">...</div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white"></h1>
<div className="flex gap-2">
{[7, 14, 30].map((d) => (
<Button
key={d}
variant={days === d ? "default" : "outline"}
size="sm"
onClick={() => setDays(d)}
>
{d}
</Button>
))}
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<div className="space-y-4">
{funnel.map((step, i) => {
const pct = (step.count / maxCount) * 100;
const prevPct = step.pct_of_prev != null ? `${step.pct_of_prev}%` : "—";
return (
<div key={i}>
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-300">{step.label}</span>
<span className="text-slate-400">
<span className="text-white font-bold">{step.count}</span>
<span className="ml-2 text-xs"> {prevPct}</span>
</span>
</div>
<div className="h-6 w-full rounded-full bg-white/5 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-cyan-500 to-blue-500 transition-all duration-500"
style={{ width: `${Math.max(pct, 2)}%` }}
/>
</div>
</div>
);
})}
{funnel.length === 0 && (
<p className="text-slate-500 text-sm py-4 text-center"></p>
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -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<EditableConfig[]>([]);
const [editing, setEditing] = useState<Record<string, string>>({});
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white"></h1>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{configs.length === 0 ? (
<p className="text-slate-500 text-sm"> API </p>
) : (
<div className="space-y-3">
{configs.map((cfg) => (
<div key={cfg.key} className="flex items-center gap-3 rounded-lg border border-white/5 bg-white/5 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-medium">{cfg.key}</div>
<div className="text-slate-500 text-xs mt-0.5">{cfg.description}</div>
</div>
<input
value={editing[cfg.key] ?? cfg.value}
onChange={(e) => 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"
/>
<Button
size="sm"
variant="outline"
disabled={saving || editing[cfg.key] === cfg.value || editing[cfg.key] == null}
onClick={() => handleSave(cfg.key)}
className="gap-1"
>
<Save className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{result && (
<p className={`mt-3 text-sm ${result.includes("失败") ? "text-amber-400" : "text-emerald-400"}`}>
{result}
</p>
)}
<p className="mt-4 text-xs text-slate-500">
API Key
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,12 @@
import { AdminSidebar } from "./AdminSidebar";
export function AdminShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<AdminSidebar />
<main className="ml-56 min-h-screen p-6">
{children}
</main>
</div>
);
}
@@ -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 (
<aside className="fixed left-0 top-0 z-40 h-screen w-56 border-r border-white/10 bg-slate-950 flex flex-col">
<div className="flex h-14 items-center gap-2 border-b border-white/10 px-4">
<LayoutDashboard className="h-5 w-5 text-cyan-400" />
<Link href="/ops/system" className="text-sm font-bold text-white">
PolyWeather Ops
</Link>
</div>
<nav className="flex-1 overflow-y-auto py-4 space-y-6">
{navGroups.map((group) => (
<div key={group.label} className="px-3">
<h4 className="mb-2 px-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500">
{group.label}
</h4>
<ul className="space-y-0.5">
{group.items.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
return (
<li key={item.href}>
<Link
href={item.href}
className={
"flex items-center gap-2.5 rounded-lg px-2 py-2 text-sm transition-colors " +
(isActive
? "bg-white/10 text-white font-medium"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200")
}
>
<item.icon className="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="border-t border-white/10 px-4 py-3">
<Link
href="/"
className="flex items-center gap-2 text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
</Link>
</div>
</aside>
);
}
@@ -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<MembershipEntry[]>([]);
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 <div className="text-slate-400 animate-pulse">...</div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white"> ({memberships.length})</h1>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-left text-slate-400">
<th className="py-3 px-4 font-medium"></th>
<th className="py-3 px-4 font-medium">ID</th>
<th className="py-3 px-4 font-medium"></th>
<th className="py-3 px-4 font-medium"></th>
<th className="py-3 px-4 font-medium"></th>
<th className="py-3 px-4 font-medium"></th>
</tr>
</thead>
<tbody>
{memberships.map((m, i) => (
<tr key={m.user_id || i} className="border-b border-white/5">
<td className="py-2.5 px-4 text-white">{m.email ?? "—"}</td>
<td className="py-2.5 px-4 text-slate-400 font-mono text-xs">{m.user_id?.slice(0, 12) ?? "—"}</td>
<td className="py-2.5 px-4">{m.plan_code ?? "—"}</td>
<td className="py-2.5 px-4 text-slate-400 text-xs">{m.starts_at?.slice(0, 10) ?? "—"}</td>
<td className="py-2.5 px-4 text-slate-400 text-xs">{m.expires_at?.slice(0, 10) ?? "—"}</td>
<td className="py-2.5 px-4">{m.queued_days ?? 0}</td>
</tr>
))}
{memberships.length === 0 && (
<tr><td colSpan={6} className="py-8 text-center text-slate-500"></td></tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -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<PaymentRuntimePayload | null>(null);
const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
const [resolving, setResolving] = useState<Set<number>>(new Set());
const load = async () => {
setLoading(true);
try {
const [rt, inc] = await Promise.all([
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
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 <div className="text-slate-400 animate-pulse">...</div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white"></h1>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
{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]) => (
<div key={k}>
<div className="text-slate-500 text-xs">{k}</div>
<div className="text-white font-mono">{String(v ?? "—")}</div>
</div>
)) : <span className="text-slate-500"></span>}
</dl>
{runtime?.receiver_contract ? (
<div className="mt-3">
<div className="text-slate-500 text-xs">receiver_contract</div>
<code className="text-xs text-blue-300 bg-black/40 rounded-lg px-2 py-1.5 block mt-1 truncate">{runtime.receiver_contract}</code>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle> ({incidents.length})</CardTitle></CardHeader>
<CardContent>
{incidents.length === 0 ? (
<span className="text-sm text-slate-500"></span>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-left text-slate-400">
<th className="py-2 pr-4 font-medium">ID</th>
<th className="py-2 pr-4 font-medium"></th>
<th className="py-2 pr-4 font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{incidents.map((inc) => (
<tr key={inc.id} className="border-b border-white/5">
<td className="py-2 pr-4 text-slate-500 font-mono">{inc.id}</td>
<td className="py-2 pr-4 text-amber-300">{inc.reason ?? "—"}</td>
<td className="py-2 pr-4 text-slate-400 text-xs">{inc.created_at?.slice(0, 19) ?? "—"}</td>
<td className="py-2">
<Button
size="sm"
variant="outline"
disabled={resolving.has(inc.id)}
onClick={() => handleResolve(inc.id)}
className="gap-1 h-7 text-xs"
>
<CheckCircle2 className="h-3 w-3" />
{resolving.has(inc.id) ? "处理中" : "标记处理"}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -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 (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-sm text-slate-500"> API </p>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<input
value={email}
onChange={(e) => 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"
/>
<select
value={planCode}
onChange={(e) => setPlanCode(e.target.value)}
className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none"
>
<option value="pro_monthly">Pro </option>
<option value="pro_quarterly">Pro </option>
<option value="pro_yearly">Pro </option>
</select>
<input
type="number"
value={days}
onChange={(e) => 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"
/>
<Button onClick={handleGrant} disabled={busy} size="sm" className="gap-1.5">
<Coins className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<input
value={email}
onChange={(e) => 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"
/>
<input
type="number"
value={extendDays}
onChange={(e) => 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"
/>
<span className="text-slate-400 text-sm self-center"></span>
<Button onClick={handleExtend} disabled={busy} size="sm" className="gap-1.5">
<ScrollText className="h-3.5 w-3.5" />
</Button>
</div>
{result && (
<p className={`mt-3 text-sm ${result.startsWith("失败") || result.startsWith("错误") ? "text-amber-400" : "text-emerald-400"}`}>
{result}
</p>
)}
</CardContent>
</Card>
</div>
);
}
@@ -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<HealthPayload | null>(null);
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const [h, s] = await Promise.all([
opsApi.health(),
opsApi.systemStatus() as Promise<SystemStatusPayload>,
]);
setHealth(h);
setStatus(s);
} catch (e) {
setError(String(e).slice(0, 200));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
if (loading) {
return <div className="text-slate-400 animate-pulse">...</div>;
}
if (error) {
return <div className="text-red-400">: {error}</div>;
}
const dbOk = status?.db?.ok ?? health?.db?.ok;
const cacheAnalysis = status?.cache?.analysis;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white"></h1>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
{/* Health badges */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Card>
<CardContent className="p-4 flex items-center gap-3">
<ShieldCheck className={`h-5 w-5 ${health?.status === "ok" ? "text-emerald-400" : "text-red-400"}`} />
<div>
<div className="text-xs text-slate-500">Health</div>
<div className="text-sm font-bold text-white">{health?.status ?? "—"}</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 flex items-center gap-3">
<Database className={`h-5 w-5 ${dbOk ? "text-emerald-400" : "text-red-400"}`} />
<div>
<div className="text-xs text-slate-500">Database</div>
<div className="text-sm font-bold text-white">{dbOk ? "OK" : "FAIL"}</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 flex items-center gap-3">
<HardDrive className="h-5 w-5 text-cyan-400" />
<div>
<div className="text-xs text-slate-500"></div>
<div className="text-sm font-bold text-white">{status?.state_storage_mode ?? "—"}</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 flex items-center gap-3">
<Cpu className="h-5 w-5 text-cyan-400" />
<div>
<div className="text-xs text-slate-500"></div>
<div className="text-sm font-bold text-white">{status?.probability?.engine_mode ?? "—"}</div>
</div>
</CardContent>
</Card>
</div>
{/* Features & Integrations */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2 text-sm">
{status?.features
? Object.entries(status.features).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span className="text-slate-400">{k}</span>
<Badge variant={v ? "default" : "secondary"}>{String(v)}</Badge>
</div>
))
: <span className="text-slate-500"></span>}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2 text-sm">
{status?.integrations
? Object.entries(status.integrations).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span className="text-slate-400">{k}</span>
<Badge variant={v ? "default" : "secondary"}>{String(v)}</Badge>
</div>
))
: <span className="text-slate-500"></span>}
</dl>
</CardContent>
</Card>
</div>
{/* Cache Analysis */}
{cacheAnalysis ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-slate-500"></div>
<div className="text-lg font-bold text-white">{cacheAnalysis.total_requests ?? 0}</div>
</div>
<div>
<div className="text-slate-500"></div>
<div className="text-lg font-bold text-emerald-400">{cacheAnalysis.cache_hits ?? 0}</div>
</div>
<div>
<div className="text-slate-500"></div>
<div className="text-lg font-bold text-amber-400">{cacheAnalysis.cache_misses ?? 0}</div>
</div>
<div>
<div className="text-slate-500"></div>
<div className="text-lg font-bold text-cyan-400">
{cacheAnalysis.hit_rate != null ? `${(cacheAnalysis.hit_rate * 100).toFixed(0)}%` : "—"}
</div>
</div>
</div>
</CardContent>
</Card>
) : null}
{/* DB Path */}
{status?.db?.db_path ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<code className="text-xs text-blue-300 bg-black/40 rounded-lg px-3 py-2 block truncate">
{status.db.db_path}
</code>
</CardContent>
</Card>
) : null}
</div>
);
}
@@ -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 (
<div className="flex justify-between py-1.5 border-b border-white/5 text-sm">
<span className="text-slate-400">{label}</span>
<span className="text-white font-medium">{value}</span>
</div>
);
}
export function TrainingPageClient() {
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<SystemStatusPayload | null>(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 <div className="text-slate-400 animate-pulse">...</div>;
if (!status) return <div className="text-red-400"></div>;
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<string, unknown> | undefined;
const lgbm = td?.lgbm_samples as Record<string, unknown> | undefined;
const modelCities = td?.model_cities;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white"></h1>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-1">
<StatRow label="行数" value={truth?.row_count ?? "—"} />
<StatRow label="城市数" value={truth?.cities_count ?? "—"} />
<StatRow label="日期范围" value={truth?.min_date && truth?.max_date ? `${truth.min_date} ~ ${truth.max_date}` : "—"} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-1">
<StatRow label="行数" value={features?.row_count ?? "—"} />
<StatRow label="城市数" value={features?.cities_count ?? "—"} />
<StatRow label="日期范围" value={features?.min_date && features?.max_date ? `${features.min_date} ~ ${features.max_date}` : "—"} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-1">
<StatRow label="城市总数" value={coverage?.total_cities ?? "—"} />
<StatRow label="有真值" value={coverage?.with_truth_rows ?? "—"} />
<StatRow label="有特征" value={coverage?.with_feature_rows ?? "—"} />
<StatRow label="有EMOS" value={coverage?.with_emos_samples ?? "—"} />
<StatRow label="有LGBM" value={coverage?.with_lgbm_samples ?? "—"} />
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>EMOS </CardTitle></CardHeader>
<CardContent>
{emos ? (
<dl className="space-y-1 text-sm">
{Object.entries(emos).map(([k, v]) => (
<StatRow key={k} label={k} value={String(v)} />
))}
</dl>
) : <span className="text-slate-500 text-sm"></span>}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>LGBM </CardTitle></CardHeader>
<CardContent>
{lgbm ? (
<dl className="space-y-1 text-sm">
{Object.entries(lgbm).map(([k, v]) => (
<StatRow key={k} label={k} value={String(v)} />
))}
</dl>
) : <span className="text-slate-500 text-sm"></span>}
</CardContent>
</Card>
</div>
{modelCities ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
{modelCities.strongest?.length ? (
<ul className="space-y-1">
{modelCities.strongest.map((c, i) => (
<li key={i} className="text-sm text-slate-300">
<span className="text-white font-medium">{c.city}</span>
<span className="text-slate-500 ml-3">EMOS:{c.emos ?? "—"} LGBM:{c.lgbm ?? "—"}</span>
</li>
))}
</ul>
) : <span className="text-slate-500 text-sm"></span>}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
{modelCities.gaps?.length ? (
<div className="flex flex-wrap gap-1.5">
{modelCities.gaps.map((c) => (
<Badge key={c} variant="secondary">{c}</Badge>
))}
</div>
) : <span className="text-slate-500 text-sm"></span>}
</CardContent>
</Card>
</div>
) : null}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-400 mb-3"></p>
<Link href="/ops/truth-history" className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-500/15 px-3 py-2 text-xs font-bold text-cyan-200 hover:bg-cyan-500/25 transition-colors">
</Link>
</CardContent>
</Card>
</div>
);
}
@@ -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<OpsUser[]>([]);
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
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 (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-white"></h1>
{/* Search */}
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<div className="flex gap-2">
<input
value={query}
onChange={(e) => 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"
/>
<Button onClick={search} disabled={searching || !query.trim()} size="sm" className="gap-1.5">
<Search className="h-3.5 w-3.5" />
</Button>
</div>
{users.length > 0 && (
<div className="mt-4 space-y-2">
{users.map((u, i) => (
<div key={u.telegram_id || i} className="flex items-center justify-between rounded-lg border border-white/5 bg-white/5 px-4 py-3">
<div>
<span className="text-white font-medium">{u.username || `TG${u.telegram_id}`}</span>
<span className="text-slate-500 text-xs ml-2">{u.supabase_email || ""}</span>
</div>
<div className="flex gap-4 text-sm">
<span className="text-cyan-400">{u.points ?? 0} </span>
<span className="text-slate-500">{u.message_count ?? 0} </span>
<span className="text-amber-400"> {u.weekly_points ?? 0}</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Grant Points */}
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<input
value={grantEmail}
onChange={(e) => 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"
/>
<input
type="number"
value={grantPoints}
onChange={(e) => 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"
/>
<Button onClick={handleGrant} disabled={grantBusy} size="sm" className="gap-1.5">
<Coins className="h-3.5 w-3.5" />
</Button>
</div>
{grantResult && (
<p className={`mt-3 text-sm ${grantResult.startsWith("成功") ? "text-emerald-400" : "text-amber-400"}`}>
{grantResult}
</p>
)}
</CardContent>
</Card>
{/* Leaderboard */}
<Card>
<CardHeader><CardTitle> Top {leaderboard.length}</CardTitle></CardHeader>
<CardContent>
{leaderboard.length === 0 ? (
<span className="text-slate-500 text-sm"></span>
) : (
<ol className="space-y-1">
{leaderboard.map((entry, i) => (
<li key={entry.telegram_id || i} className="flex justify-between text-sm py-1.5 border-b border-white/5">
<span>
<span className="text-slate-500 w-6 inline-block">#{entry.rank ?? i + 1}</span>
<span className="text-white">{entry.username ?? `TG${entry.telegram_id}`}</span>
</span>
<span className="text-cyan-400 font-medium">{entry.weekly_points ?? 0} </span>
</li>
))}
</ol>
)}
<Button variant="outline" size="sm" onClick={loadLeaderboard} className="mt-3 gap-1.5">
<RefreshCcw className="h-3 w-3" />
</Button>
</CardContent>
</Card>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
type FetchOptions = RequestInit & { timeoutMs?: number };
async function opsFetch<T>(url: string, options?: FetchOptions): Promise<T> {
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<T>;
}
export const opsApi = {
health() {
return opsFetch<{ status: string }>("/api/healthz");
},
systemStatus() {
return opsFetch<Record<string, unknown>>("/api/system/status");
},
paymentRuntime() {
return opsFetch<Record<string, unknown>>("/api/payments/runtime");
},
funnel(days = 30) {
return opsFetch<Record<string, unknown>>(`/api/ops/analytics/funnel?days=${days}`);
},
users(q: string, limit = 20) {
return opsFetch<Record<string, unknown>>(`/api/ops/users?q=${encodeURIComponent(q)}&limit=${limit}`);
},
grantPoints(email: string, points: number) {
return opsFetch<Record<string, unknown>>("/api/ops/users/grant-points", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, points }),
});
},
leaderboard(limit = 10) {
return opsFetch<Record<string, unknown>>(`/api/ops/leaderboard/weekly?limit=${limit}`);
},
memberships() {
return opsFetch<Record<string, unknown>>("/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<Record<string, unknown>>(`/api/ops/payments/incidents?${params}`);
},
resolveIncident(eventId: string | number) {
return opsFetch<Record<string, unknown>>(`/api/ops/payments/incidents/${eventId}/resolve`, {
method: "POST",
});
},
truthHistory(params: Record<string, string>) {
const qs = new URLSearchParams(params).toString();
return opsFetch<Record<string, unknown>>(`/api/ops/truth-history?${qs}`);
},
};
+148
View File
@@ -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<string, unknown>;
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<string, unknown>;
};
integrations?: Record<string, unknown>;
training_data?: {
truth_records?: {
row_count?: number;
cities_count?: number;
min_date?: string | null;
max_date?: string | null;
source_counts?: Record<string, number>;
};
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<string, unknown>;
lgbm_samples?: Record<string, unknown>;
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<Record<string, unknown>>;
};
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<Record<string, unknown>>;
total?: number;
};
export type ConfigEntry = {
key: string;
value: string;
description: string;
};