完善后台管理功能:订阅管理增强、训练精度面板、支付记录查询

This commit is contained in:
2569718930@qq.com
2026-05-20 16:50:19 +08:00
parent 05f5d3c2dd
commit 66512d2623
8 changed files with 356 additions and 9 deletions
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { RefreshCcw } from "lucide-react";
import { RefreshCcw, X, Search } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
@@ -13,12 +13,30 @@ import {
type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number };
type SubRow = {
id?: string;
status?: string;
plan_code?: string;
source?: string;
starts_at?: string;
expires_at?: string;
created_at?: string;
updated_at?: string;
};
export function MembershipsPageClient() {
const [loading, setLoading] = useState(true);
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
const [growth, setGrowth] = useState<GrowthPoint[]>([]);
const [filter, setFilter] = useState<"all" | "paid" | "trial">("all");
// Detail modal state
const [detailEmail, setDetailEmail] = useState<string | null>(null);
const [detailUserId, setDetailUserId] = useState<string>("");
const [detailRows, setDetailRows] = useState<SubRow[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string>("");
const load = async () => {
setLoading(true);
try {
@@ -47,6 +65,46 @@ export function MembershipsPageClient() {
return code;
};
const sourceLabel = (source?: string) => {
if (!source) return "—";
if (source === "payment_contract") return "链上支付";
if (source === "ops_manual_grant") return "后台赠送";
if (source === "signup_trial") return "注册体验";
if (source === "weekly_reward") return "周奖励";
return source;
};
const statusBadge = (status?: string) => {
if (status === "active") return <span className="inline-flex items-center rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-medium text-emerald-400">active</span>;
if (status === "expired") return <span className="inline-flex items-center rounded-full bg-red-500/15 px-2 py-0.5 text-[11px] font-medium text-red-400">expired</span>;
if (status === "cancelled") return <span className="inline-flex items-center rounded-full bg-slate-500/15 px-2 py-0.5 text-[11px] font-medium text-slate-400">cancelled</span>;
return <span className="inline-flex items-center rounded-full bg-slate-500/15 px-2 py-0.5 text-[11px] font-medium text-slate-400">{status ?? "—"}</span>;
};
const openDetail = async (email: string) => {
if (!email) return;
setDetailEmail(email);
setDetailLoading(true);
setDetailError("");
setDetailRows([]);
setDetailUserId("");
try {
const data = await opsApi.userSubscriptions(email);
setDetailUserId(data.user_id ?? "");
setDetailRows(data.subscriptions ?? []);
} catch (e: unknown) {
setDetailError(e instanceof Error ? e.message : "查询失败");
}
setDetailLoading(false);
};
const closeDetail = () => {
setDetailEmail(null);
setDetailRows([]);
setDetailUserId("");
setDetailError("");
};
if (loading) return <div className="text-slate-400 animate-pulse">...</div>;
return (
@@ -154,7 +212,7 @@ export function MembershipsPageClient() {
</thead>
<tbody>
{filtered.map((m, i) => (
<tr key={m.user_id || i} className="border-b border-white/5">
<tr key={m.user_id || i} className="border-b border-white/5 hover:bg-white/[0.02] transition-colors">
<td className="py-2.5 px-4">
{m.is_trial ? (
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] font-medium text-amber-400"></span>
@@ -162,7 +220,16 @@ export function MembershipsPageClient() {
<span className="inline-flex items-center rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-medium text-emerald-400"></span>
)}
</td>
<td className="py-2.5 px-4 text-white">{m.email ?? "—"}</td>
<td className="py-2.5 px-4">
<button
className="text-white hover:text-cyan-400 transition-colors inline-flex items-center gap-1.5 group"
onClick={() => openDetail(m.email ?? "")}
title="查看全部订阅记录"
>
{m.email ?? "—"}
<Search className="h-3 w-3 opacity-0 group-hover:opacity-60 transition-opacity" />
</button>
</td>
<td className="py-2.5 px-4">{planLabel(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>
@@ -177,6 +244,81 @@ export function MembershipsPageClient() {
</div>
</CardContent>
</Card>
{/* Subscription detail modal */}
{detailEmail && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={closeDetail}>
<div
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[80vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<div>
<h2 className="text-lg font-bold text-white"></h2>
<p className="text-xs text-slate-400 mt-0.5">
{detailEmail}
{detailUserId && <span className="ml-2 text-slate-500">ID: {detailUserId.slice(0, 8)}</span>}
</p>
</div>
<button onClick={closeDetail} className="text-slate-400 hover:text-white transition-colors p-1 rounded-lg hover:bg-white/10">
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto flex-1 px-6 py-4">
{detailLoading && (
<div className="text-slate-400 animate-pulse py-8 text-center">...</div>
)}
{detailError && (
<div className="text-red-400 py-8 text-center text-sm">{detailError}</div>
)}
{!detailLoading && !detailError && detailRows.length === 0 && (
<div className="text-slate-500 py-8 text-center text-sm"></div>
)}
{!detailLoading && detailRows.length > 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-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
</tr>
</thead>
<tbody>
{detailRows.map((row, i) => {
const isExpired = row.expires_at && new Date(row.expires_at) < new Date();
return (
<tr key={row.id ?? i} className={`border-b border-white/5 ${isExpired ? "opacity-50" : ""}`}>
<td className="py-2 px-3">{statusBadge(row.status)}</td>
<td className="py-2 px-3 text-white text-xs">{planLabel(row.plan_code)}</td>
<td className="py-2 px-3 text-slate-400 text-xs">{sourceLabel(row.source)}</td>
<td className="py-2 px-3 text-slate-400 text-xs font-mono">{row.starts_at?.slice(0, 19)?.replace("T", " ") ?? "—"}</td>
<td className="py-2 px-3 text-xs font-mono">
<span className={isExpired ? "text-red-400" : "text-emerald-400"}>
{row.expires_at?.slice(0, 19)?.replace("T", " ") ?? "—"}
</span>
</td>
<td className="py-2 px-3 text-slate-500 text-xs font-mono">{row.created_at?.slice(0, 19)?.replace("T", " ") ?? "—"}</td>
</tr>
);
})}
</tbody>
</table>
<div className="text-xs text-slate-500 mt-3 px-1">
{detailRows.length} · UTC
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -18,15 +18,38 @@ function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
);
}
interface CityAccuracy {
city_id: string;
name: string;
deb?: {
hit_rate: number;
mae: number;
total_days: number;
details_str: string;
} | null;
mu?: {
mae: number;
hit_rate: number;
brier_score: number | null;
total_days: number;
details_str: string;
} | null;
}
export function TrainingPageClient() {
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [accuracy, setAccuracy] = useState<CityAccuracy[] | null>(null);
const load = async () => {
setLoading(true);
try {
const s = await opsApi.systemStatus() as SystemStatusPayload;
const [s, accData] = await Promise.all([
opsApi.systemStatus() as Promise<SystemStatusPayload>,
opsApi.trainingAccuracy().catch(() => ({ accuracy: [] }))
]);
setStatus(s);
setAccuracy(accData.accuracy);
} catch { /* */ }
setLoading(false);
};
@@ -110,6 +133,105 @@ export function TrainingPageClient() {
</div>
) : null}
<Card>
<CardHeader>
<CardTitle> (DEB & )</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left text-slate-300">
<thead className="text-xs uppercase bg-slate-800/50 text-slate-400">
<tr>
<th scope="col" className="px-4 py-3"></th>
<th scope="col" className="px-4 py-3 text-center">DEB </th>
<th scope="col" className="px-4 py-3 text-center">DEB MAE</th>
<th scope="col" className="px-4 py-3 text-center">DEB </th>
<th scope="col" className="px-4 py-3 text-center"> μ </th>
<th scope="col" className="px-4 py-3 text-center"> μ MAE</th>
<th scope="col" className="px-4 py-3 text-center">Brier Score</th>
<th scope="col" className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{accuracy && accuracy.length > 0 ? (
accuracy.map((row) => (
<tr key={row.city_id} className="hover:bg-white/5 transition-colors">
<td className="px-4 py-3 font-medium text-white capitalize">
{row.name}
<span className="text-xs text-slate-500 block font-mono">{row.city_id}</span>
</td>
<td className="px-4 py-3 text-center">
{row.deb ? (
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
row.deb.hit_rate >= 80
? "bg-green-500/15 text-green-400"
: row.deb.hit_rate >= 60
? "bg-yellow-500/15 text-yellow-400"
: "bg-red-500/15 text-red-400"
}`}>
{row.deb.hit_rate.toFixed(0)}%
</span>
) : (
<span className="text-slate-600"></span>
)}
</td>
<td className="px-4 py-3 text-center font-mono">
{row.deb ? `${row.deb.mae.toFixed(1)}°` : "—"}
</td>
<td className="px-4 py-3 text-center text-slate-400">
{row.deb ? row.deb.total_days : "—"}
</td>
<td className="px-4 py-3 text-center">
{row.mu ? (
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
row.mu.hit_rate >= 80
? "bg-green-500/15 text-green-400"
: row.mu.hit_rate >= 60
? "bg-yellow-500/15 text-yellow-400"
: "bg-red-500/15 text-red-400"
}`}>
{row.mu.hit_rate.toFixed(0)}%
</span>
) : (
<span className="text-slate-600"></span>
)}
</td>
<td className="px-4 py-3 text-center font-mono">
{row.mu ? `${row.mu.mae.toFixed(1)}°` : "—"}
</td>
<td className="px-4 py-3 text-center font-mono">
{row.mu && row.mu.brier_score !== null ? (
<span className={`${
row.mu.brier_score <= 0.1
? "text-green-400 font-bold"
: row.mu.brier_score <= 0.25
? "text-yellow-400"
: "text-red-400"
}`}>
{row.mu.brier_score.toFixed(3)}
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-3 text-center text-slate-400">
{row.mu ? row.mu.total_days : "—"}
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
daily_records.json
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
@@ -124,3 +246,4 @@ export function TrainingPageClient() {
</div>
);
}
+21
View File
@@ -105,4 +105,25 @@ export const opsApi = {
count: number;
}>(`/api/ops/subscriptions/user?email=${encodeURIComponent(email)}`);
},
trainingAccuracy() {
return opsFetch<{
accuracy: Array<{
city_id: string;
name: string;
deb?: {
hit_rate: number;
mae: number;
total_days: number;
details_str: string;
} | null;
mu?: {
mae: number;
hit_rate: number;
brier_score: number | null;
total_days: number;
details_str: string;
} | null;
}>;
}>("/api/ops/training/accuracy");
},
};