mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: enhance subscription risk review workflow
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { ChevronDown, Globe2, MapPin } from "lucide-react";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import type { SubscriptionRiskGeoSummary } from "@/services/subscription-risk-review";
|
||||
|
||||
function projectPoint(latitude: number, longitude: number) {
|
||||
return {
|
||||
x: ((longitude + 180) / 360) * 360,
|
||||
y: ((90 - latitude) / 180) * 180,
|
||||
};
|
||||
}
|
||||
|
||||
function radiusForAccess(count: number) {
|
||||
return Math.min(7, Math.max(3, 2 + Math.sqrt(count)));
|
||||
}
|
||||
|
||||
function WorldRiskMap({ summary }: { summary: SubscriptionRiskGeoSummary }) {
|
||||
const points = summary.points.slice(0, 60);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border/70 bg-muted/20">
|
||||
<svg
|
||||
viewBox="0 0 360 180"
|
||||
className="h-48 w-full"
|
||||
role="img"
|
||||
aria-label="订阅访问 IP 世界地图分布"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="risk-map-water" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--muted)" stopOpacity="0.68" />
|
||||
<stop offset="100%" stopColor="var(--card)" stopOpacity="0.94" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="360" height="180" rx="12" fill="url(#risk-map-water)" />
|
||||
{[-120, -60, 0, 60, 120].map((longitude) => {
|
||||
const x = ((longitude + 180) / 360) * 360;
|
||||
return <line key={longitude} x1={x} x2={x} y1="12" y2="168" stroke="var(--border)" strokeDasharray="2 5" strokeOpacity="0.7" />;
|
||||
})}
|
||||
{[-45, 0, 45].map((latitude) => {
|
||||
const y = ((90 - latitude) / 180) * 180;
|
||||
return <line key={latitude} x1="12" x2="348" y1={y} y2={y} stroke="var(--border)" strokeDasharray="2 5" strokeOpacity="0.7" />;
|
||||
})}
|
||||
<g fill="var(--card)" stroke="var(--border)" strokeWidth="1" opacity="0.92">
|
||||
<path d="M39 50 61 35 91 39 116 57 106 78 82 80 72 106 54 97 36 69Z" />
|
||||
<path d="M86 93 105 103 113 129 100 158 82 144 75 118Z" />
|
||||
<path d="M132 47 160 36 191 41 215 35 251 48 286 58 303 79 276 95 246 89 220 102 190 91 169 98 144 82Z" />
|
||||
<path d="M174 89 198 98 208 126 195 158 172 139 158 107Z" />
|
||||
<path d="M281 116 306 110 326 126 316 147 289 145 270 130Z" />
|
||||
<path d="M295 72 330 69 342 83 325 94 300 89Z" />
|
||||
<path d="M126 33 146 26 168 32 151 42Z" />
|
||||
</g>
|
||||
<g>
|
||||
{points.map((point) => {
|
||||
const { x, y } = projectPoint(point.latitude, point.longitude);
|
||||
return (
|
||||
<g key={point.key}>
|
||||
<title>{point.ip + " / " + point.country + " / " + point.region + " / " + point.city}</title>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={radiusForAccess(point.accessCount) + 2}
|
||||
fill={point.allowed ? "var(--primary)" : "var(--destructive)"}
|
||||
opacity="0.16"
|
||||
/>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={radiusForAccess(point.accessCount)}
|
||||
fill={point.allowed ? "var(--primary)" : "var(--destructive)"}
|
||||
stroke="var(--card)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
<div className="flex items-center justify-between border-t border-border/60 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>{points.length > 0 ? "已标注 " + points.length + " 个坐标点" : "没有可用经纬度坐标"}</span>
|
||||
<span>圆点越大表示访问越集中</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionRiskGeoSummary }) {
|
||||
return (
|
||||
<details className="group min-w-[24rem] rounded-lg border border-border/70 bg-muted/20 text-sm">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background text-primary">
|
||||
<Globe2 className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium">地区与 IP 证据</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{summary.uniqueCountryCount} 国 / {summary.uniqueRegionCount} 省区 / {summary.uniqueCityCount} 城市 / {summary.uniqueIpCount} IP
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
|
||||
<div className="border-t border-border/60 p-3">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(20rem,1.1fr)_minmax(16rem,0.9fr)]">
|
||||
<WorldRiskMap summary={summary} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-md border border-border/60 bg-background/70 p-2">
|
||||
<p className="text-xs text-muted-foreground">访问记录</p>
|
||||
<p className="mt-1 font-mono text-base font-semibold">{summary.totalLogs}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/60 bg-background/70 p-2">
|
||||
<p className="text-xs text-muted-foreground">不同 IP</p>
|
||||
<p className="mt-1 font-mono text-base font-semibold">{summary.uniqueIpCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-44 space-y-2 overflow-auto pr-1">
|
||||
{summary.countries.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border/70 p-3 text-xs text-muted-foreground">
|
||||
暂无可识别地区数据,可能是 GeoIP 未命中或访问来源未携带有效 IP。
|
||||
</p>
|
||||
) : (
|
||||
summary.countries.map((country) => (
|
||||
<div key={country.country} className="rounded-md border border-border/60 bg-background/70 p-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="break-words font-medium">{country.country}</p>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">{country.accessCount} 次</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{country.ipCount} IP · {country.regionCount} 省/地区 · {country.cityCount} 城市
|
||||
</p>
|
||||
{(country.topRegions.length > 0 || country.topCities.length > 0) && (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
省区:{country.topRegions.join("、") || "未识别"};城市:{country.topCities.join("、") || "未识别"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<MapPin className="size-3.5" /> 最近访问明细
|
||||
</div>
|
||||
<div className="grid gap-2 lg:grid-cols-2">
|
||||
{summary.recentAccesses.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border/70 p-3 text-xs text-muted-foreground">暂无访问明细。</p>
|
||||
) : (
|
||||
summary.recentAccesses.slice(0, 6).map((access) => (
|
||||
<div key={access.id} className="min-w-0 rounded-md border border-border/60 bg-background/70 p-2 text-xs leading-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono">{access.ip}</span>
|
||||
<StatusBadge tone={access.allowed ? "success" : "warning"}>
|
||||
{access.allowed ? "放行" : access.reason || "拦截"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1 break-words text-muted-foreground">{access.location}</p>
|
||||
<p className="text-muted-foreground">{formatDate(access.createdAt)}</p>
|
||||
{access.userAgent && <p className="mt-1 truncate text-muted-foreground">{access.userAgent}</p>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/shared/domain-badges";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
|
||||
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
|
||||
import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details";
|
||||
import { formatDate, formatDateShort } from "@/lib/utils";
|
||||
import type { SubscriptionRiskEventRow } from "../risk-data";
|
||||
|
||||
@@ -58,6 +59,17 @@ function reviewStatusTone(status: SubscriptionRiskEvent["reviewStatus"]): Status
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function finalActionLabel(action: SubscriptionRiskEvent["finalAction"]) {
|
||||
switch (action) {
|
||||
case "RESTORE_ACCESS":
|
||||
return "已解除限制";
|
||||
case "KEEP_RESTRICTED":
|
||||
return "保持封禁/暂停";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
|
||||
if (!event.subscription) {
|
||||
return (
|
||||
@@ -91,7 +103,9 @@ function UserCell({ event }: { event: SubscriptionRiskEventRow }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="max-w-56 break-all font-medium">{event.user.email}</p>
|
||||
<Link href={"/admin/users/" + event.user.id} className="block max-w-56 break-all font-medium text-foreground hover:underline">
|
||||
{event.user.email}
|
||||
</Link>
|
||||
<p className="max-w-52 break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
|
||||
<UserStatusBadge status={event.user.status} />
|
||||
</div>
|
||||
@@ -138,11 +152,14 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-mono text-xs">{event.ip || "未知 IP"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-mono text-xs">{event.ip || "未知 IP"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount}
|
||||
</p>
|
||||
</div>
|
||||
<SubscriptionRiskGeoDetails summary={event.geoSummary} />
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
@@ -150,6 +167,17 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
|
||||
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>
|
||||
{reviewStatusLabel(event.reviewStatus)}
|
||||
</StatusBadge>
|
||||
{(event.reportSentAt || event.userRestrictionActive || event.finalAction) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{event.reportSentAt && <StatusBadge tone="info">已发送报告</StatusBadge>}
|
||||
{event.userRestrictionActive && <StatusBadge tone="danger">用户端限制中</StatusBadge>}
|
||||
{finalActionLabel(event.finalAction) && (
|
||||
<StatusBadge tone={event.finalAction === "RESTORE_ACCESS" ? "success" : "warning"}>
|
||||
{finalActionLabel(event.finalAction)}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(event.reviewedByEmail || event.reviewNote) && (
|
||||
<div className="max-w-52 text-xs leading-5 text-muted-foreground">
|
||||
{event.reviewedByEmail && <p className="break-all">{event.reviewedByEmail}</p>}
|
||||
@@ -165,6 +193,11 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
|
||||
eventId={event.id}
|
||||
reviewStatus={event.reviewStatus}
|
||||
canRestoreSubscription={event.canRestoreSubscription}
|
||||
restorableSubscriptionCount={event.restorableSubscriptionCount}
|
||||
riskReport={event.riskReport}
|
||||
reportSentAt={event.reportSentAt}
|
||||
userRestrictionActive={event.userRestrictionActive}
|
||||
finalAction={event.finalAction}
|
||||
/>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Prisma, SubscriptionRiskEvent } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
import {
|
||||
buildSubscriptionRiskGeoSummary,
|
||||
getSubscriptionRiskAccessLogsForEvent,
|
||||
type SubscriptionRiskGeoSummary,
|
||||
} from "@/services/subscription-risk-review";
|
||||
|
||||
type RiskUser = {
|
||||
id: string;
|
||||
@@ -24,6 +29,8 @@ export type SubscriptionRiskEventRow = SubscriptionRiskEvent & {
|
||||
user: RiskUser | null;
|
||||
subscription: RiskSubscription | null;
|
||||
canRestoreSubscription: boolean;
|
||||
restorableSubscriptionCount: number;
|
||||
geoSummary: SubscriptionRiskGeoSummary;
|
||||
};
|
||||
|
||||
async function searchRelatedIds(q: string) {
|
||||
@@ -102,7 +109,8 @@ export async function getSubscriptionRiskEvents(
|
||||
const eventUserIds = Array.from(new Set(events.map((event) => event.userId).filter(Boolean))) as string[];
|
||||
const eventSubscriptionIds = Array.from(new Set(events.map((event) => event.subscriptionId).filter(Boolean))) as string[];
|
||||
|
||||
const [users, subscriptions] = await Promise.all([
|
||||
const now = new Date();
|
||||
const [users, subscriptions, restorableSubscriptions, geoSummaries] = await Promise.all([
|
||||
eventUserIds.length > 0
|
||||
? prisma.user.findMany({
|
||||
where: { id: { in: eventUserIds } },
|
||||
@@ -121,25 +129,57 @@ export async function getSubscriptionRiskEvents(
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
eventUserIds.length > 0
|
||||
? prisma.userSubscription.findMany({
|
||||
where: {
|
||||
userId: { in: eventUserIds },
|
||||
status: "SUSPENDED",
|
||||
endDate: { gt: now },
|
||||
plan: { type: "PROXY" },
|
||||
},
|
||||
select: { id: true, userId: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
Promise.all(
|
||||
events.map(async (event) => {
|
||||
const logs = await getSubscriptionRiskAccessLogsForEvent(event);
|
||||
return [event.id, buildSubscriptionRiskGeoSummary(logs)] as const;
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const userById = new Map(users.map((user) => [user.id, user]));
|
||||
const subscriptionById = new Map(subscriptions.map((subscription) => [subscription.id, subscription]));
|
||||
const now = new Date();
|
||||
const geoSummaryByEventId = new Map(geoSummaries);
|
||||
const restorableCountByUserId = new Map<string, number>();
|
||||
for (const subscription of restorableSubscriptions) {
|
||||
restorableCountByUserId.set(
|
||||
subscription.userId,
|
||||
(restorableCountByUserId.get(subscription.userId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const rows: SubscriptionRiskEventRow[] = events.map((event) => {
|
||||
const subscription = event.subscriptionId ? subscriptionById.get(event.subscriptionId) ?? null : null;
|
||||
const user = subscription?.user ?? (event.userId ? userById.get(event.userId) ?? null : null);
|
||||
const singleRestorable = Boolean(
|
||||
subscription
|
||||
&& subscription.status === "SUSPENDED"
|
||||
&& subscription.endDate > now
|
||||
&& event.reviewStatus !== "RESOLVED",
|
||||
);
|
||||
const aggregateRestorableCount = event.kind === "AGGREGATE" && event.userId
|
||||
? restorableCountByUserId.get(event.userId) ?? 0
|
||||
: 0;
|
||||
const restorableSubscriptionCount = singleRestorable ? 1 : aggregateRestorableCount;
|
||||
|
||||
return {
|
||||
...event,
|
||||
user,
|
||||
subscription,
|
||||
canRestoreSubscription: Boolean(
|
||||
subscription
|
||||
&& subscription.status === "SUSPENDED"
|
||||
&& subscription.endDate > now
|
||||
&& event.reviewStatus !== "RESOLVED",
|
||||
),
|
||||
canRestoreSubscription: restorableSubscriptionCount > 0 && event.reviewStatus !== "RESOLVED",
|
||||
restorableSubscriptionCount,
|
||||
geoSummary: geoSummaryByEventId.get(event.id) ?? buildSubscriptionRiskGeoSummary([]),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ export function SubscriptionAccessRiskSection({
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="break-all font-medium">{owner.email}</span>
|
||||
<Link href={"/admin/users/" + owner.id} className="break-all font-medium hover:underline">{owner.email}</Link>
|
||||
<UserStatusBadge status={owner.status} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">{owner.name || "未设置昵称"}</p>
|
||||
@@ -179,6 +179,11 @@ export function SubscriptionAccessRiskSection({
|
||||
eventId={event.id}
|
||||
reviewStatus={event.reviewStatus}
|
||||
canRestoreSubscription={canRestoreFromEvent(event, subscription)}
|
||||
restorableSubscriptionCount={canRestoreFromEvent(event, subscription) ? 1 : 0}
|
||||
riskReport={event.riskReport}
|
||||
reportSentAt={event.reportSentAt}
|
||||
userRestrictionActive={event.userRestrictionActive}
|
||||
finalAction={event.finalAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
260
src/app/(admin)/admin/users/[id]/page.tsx
Normal file
260
src/app/(admin)/admin/users/[id]/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import {
|
||||
OrderStatusBadge,
|
||||
SubscriptionStatusBadge,
|
||||
SubscriptionTypeBadge,
|
||||
UserRoleBadge,
|
||||
UserStatusBadge,
|
||||
orderKindLabels,
|
||||
} from "@/components/shared/domain-badges";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
|
||||
import {
|
||||
SupportTicketPriorityBadge,
|
||||
SupportTicketStatusBadge,
|
||||
} from "@/components/support/ticket-badges";
|
||||
import { formatBytes, formatDate, formatDateShort } from "@/lib/utils";
|
||||
import { reasonLabel } from "@/services/subscription-risk-review";
|
||||
import { UserActions } from "../user-actions";
|
||||
import { getAdminUserDetail } from "./user-detail-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "用户详情",
|
||||
description: "查看用户资料、订阅、订单、工单和风控记录。",
|
||||
};
|
||||
|
||||
function MetricCard({ label, value, hint }: { label: string; value: ReactNode; hint?: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="mt-2 min-h-7 break-words text-xl font-semibold tracking-[-0.02em]">{value}</div>
|
||||
{hint && <div className="mt-1 text-xs leading-5 text-muted-foreground">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function reviewStatusLabel(status: "OPEN" | "ACKNOWLEDGED" | "RESOLVED") {
|
||||
if (status === "RESOLVED") return "已解决";
|
||||
if (status === "ACKNOWLEDGED") return "已确认";
|
||||
return "待处理";
|
||||
}
|
||||
|
||||
function reviewStatusTone(status: "OPEN" | "ACKNOWLEDGED" | "RESOLVED"): StatusTone {
|
||||
if (status === "RESOLVED") return "success";
|
||||
if (status === "ACKNOWLEDGED") return "info";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
export default async function AdminUserDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const data = await getAdminUserDetail(id);
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { user, subscriptions, orders, riskEvents, supportTickets } = data;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="用户详情"
|
||||
title={user.email}
|
||||
description={user.name || "未设置昵称"}
|
||||
actions={<UserActions user={user} />}
|
||||
/>
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<MetricCard
|
||||
label="账号状态"
|
||||
value={(
|
||||
<span className="flex flex-wrap gap-2">
|
||||
<UserStatusBadge status={user.status} />
|
||||
<UserRoleBadge role={user.role} />
|
||||
</span>
|
||||
)}
|
||||
hint={user.emailVerifiedAt ? "邮箱已验证" : "邮箱未验证"}
|
||||
/>
|
||||
<MetricCard label="订阅" value={user._count.subscriptions} hint="用户持有的全部订阅" />
|
||||
<MetricCard label="订单" value={user._count.orders} hint="历史订单数量" />
|
||||
<MetricCard label="工单" value={user._count.supportTickets} hint="售后沟通记录" />
|
||||
<MetricCard label="注册时间" value={formatDateShort(user.createdAt)} hint={user.invitedBy ? "邀请人:" + user.invitedBy.email : "非邀请注册或邀请人已删除"} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border/70 bg-card p-4 text-sm leading-6">
|
||||
<SectionHeader
|
||||
title="账号资料"
|
||||
description="用于风控判断时快速确认用户基础信息。"
|
||||
actions={<Link href={"/admin/subscription-risk?q=" + encodeURIComponent(user.email)} className="text-sm font-medium text-primary hover:underline">查看该用户风控</Link>}
|
||||
/>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">用户 ID</p>
|
||||
<p className="mt-1 break-all font-mono text-xs">{user.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">邀请码</p>
|
||||
<p className="mt-1 break-all font-mono text-xs">{user.inviteCode || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">注册时间</p>
|
||||
<p className="mt-1">{formatDate(user.createdAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">更新时间</p>
|
||||
<p className="mt-1">{formatDate(user.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<SectionHeader title="订阅" description="最近创建的订阅与当前状态。" />
|
||||
<DataTableShell isEmpty={subscriptions.length === 0} emptyTitle="暂无订阅" emptyDescription="这个用户还没有订阅记录。">
|
||||
<DataTable aria-label="用户订阅" className="min-w-[820px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>套餐</DataTableHeadCell>
|
||||
<DataTableHeadCell>类型</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell>流量</DataTableHeadCell>
|
||||
<DataTableHeadCell>到期</DataTableHeadCell>
|
||||
<DataTableHeadCell>创建时间</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{subscriptions.map((subscription) => (
|
||||
<DataTableRow key={subscription.id}>
|
||||
<DataTableCell>
|
||||
<Link href={"/admin/subscriptions/" + subscription.id} className="font-medium hover:underline">
|
||||
{subscription.plan.name}
|
||||
</Link>
|
||||
</DataTableCell>
|
||||
<DataTableCell><SubscriptionTypeBadge type={subscription.plan.type} /></DataTableCell>
|
||||
<DataTableCell><SubscriptionStatusBadge status={subscription.status} /></DataTableCell>
|
||||
<DataTableCell className="text-xs text-muted-foreground">
|
||||
{formatBytes(subscription.trafficUsed)} / {subscription.trafficLimit ? formatBytes(subscription.trafficLimit) : "不限"}
|
||||
</DataTableCell>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.endDate)}</DataTableCell>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.createdAt)}</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<SectionHeader title="近期风控" description="这个用户最近触发的订阅访问风控事件。" />
|
||||
<DataTableShell isEmpty={riskEvents.length === 0} emptyTitle="暂无风控事件" emptyDescription="目前没有该用户的订阅风控记录。">
|
||||
<DataTable aria-label="用户风控事件" className="min-w-[760px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||
<DataTableHeadCell>判定</DataTableHeadCell>
|
||||
<DataTableHeadCell>地区/IP</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{riskEvents.map((event) => (
|
||||
<DataTableRow key={event.id}>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDate(event.createdAt)}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="space-y-2">
|
||||
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>{reasonLabel(event.reason)}</StatusBadge>
|
||||
<p className="max-w-lg text-xs leading-5 text-muted-foreground">{event.message}</p>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="text-xs text-muted-foreground">
|
||||
<p className="font-mono text-foreground">{event.ip || "未知 IP"}</p>
|
||||
<p>城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount}</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<SectionHeader title="近期订单" description="最近的购买、续费和增流量订单。" />
|
||||
<DataTableShell isEmpty={orders.length === 0} emptyTitle="暂无订单" emptyDescription="这个用户还没有订单记录。">
|
||||
<DataTable aria-label="用户订单" className="min-w-[680px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>套餐</DataTableHeadCell>
|
||||
<DataTableHeadCell>类型</DataTableHeadCell>
|
||||
<DataTableHeadCell>金额</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{orders.map((order) => (
|
||||
<DataTableRow key={order.id}>
|
||||
<DataTableCell className="max-w-52 truncate font-medium">{order.plan.name}</DataTableCell>
|
||||
<DataTableCell>{orderKindLabels[order.kind]}</DataTableCell>
|
||||
<DataTableCell className="font-mono">¥{Number(order.amount).toFixed(2)}</DataTableCell>
|
||||
<DataTableCell><OrderStatusBadge status={order.status} /></DataTableCell>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(order.createdAt)}</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SectionHeader title="近期工单" description="用户与客服之间的最近沟通。" />
|
||||
<DataTableShell isEmpty={supportTickets.length === 0} emptyTitle="暂无工单" emptyDescription="这个用户还没有提交工单。">
|
||||
<DataTable aria-label="用户工单" className="min-w-[680px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>标题</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell>优先级</DataTableHeadCell>
|
||||
<DataTableHeadCell>更新</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{supportTickets.map((ticket) => (
|
||||
<DataTableRow key={ticket.id}>
|
||||
<DataTableCell>
|
||||
<Link href={"/admin/support/" + ticket.id} className="max-w-72 truncate font-medium hover:underline">
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
</DataTableCell>
|
||||
<DataTableCell><SupportTicketStatusBadge status={ticket.status} /></DataTableCell>
|
||||
<DataTableCell><SupportTicketPriorityBadge priority={ticket.priority} /></DataTableCell>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(ticket.updatedAt)}</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
</div>
|
||||
</section>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
82
src/app/(admin)/admin/users/[id]/user-detail-data.ts
Normal file
82
src/app/(admin)/admin/users/[id]/user-detail-data.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const adminUserDetailInclude = {
|
||||
invitedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
subscriptions: true,
|
||||
orders: true,
|
||||
invitedUsers: true,
|
||||
supportTickets: true,
|
||||
notifications: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.UserInclude;
|
||||
|
||||
export type AdminUserDetail = Prisma.UserGetPayload<{
|
||||
include: typeof adminUserDetailInclude;
|
||||
}>;
|
||||
|
||||
export async function getAdminUserDetail(userId: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: adminUserDetailInclude,
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const [subscriptions, orders, riskEvents, supportTickets] = await Promise.all([
|
||||
prisma.userSubscription.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
endDate: true,
|
||||
trafficUsed: true,
|
||||
trafficLimit: true,
|
||||
createdAt: true,
|
||||
plan: { select: { name: true, type: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 12,
|
||||
}),
|
||||
prisma.order.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
status: true,
|
||||
kind: true,
|
||||
createdAt: true,
|
||||
plan: { select: { name: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
}),
|
||||
prisma.subscriptionRiskEvent.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
}),
|
||||
prisma.supportTicket.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
priority: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 8,
|
||||
}),
|
||||
]);
|
||||
|
||||
return { user, subscriptions, orders, riskEvents, supportTickets };
|
||||
}
|
||||
Reference in New Issue
Block a user