后台管理系统基础框架:侧边栏布局、9 个页面模块、共享类型和 API 客户端
This commit is contained in:
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/ops/layout/AdminShell";
|
||||
|
||||
export default function OpsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AdminShell>{children}</AdminShell>;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user