Files
J-Board-Lite/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx
2026-04-29 17:18:36 +10:00

274 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import type { SubscriptionRiskEvent } from "@prisma/client";
import { ChevronDown } from "lucide-react";
import {
SubscriptionStatusBadge,
SubscriptionTypeBadge,
UserStatusBadge,
} from "@/components/shared/domain-badges";
import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { formatDate, formatDateShort } from "@/lib/utils";
import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details";
import type { SubscriptionRiskEventRow } from "../risk-data";
function kindLabel(kind: SubscriptionRiskEvent["kind"]) {
return kind === "AGGREGATE" ? "总订阅" : "单订阅";
}
function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
switch (reason) {
case "CITY_VARIANCE_WARNING":
return "城市异常警告";
case "CITY_VARIANCE_SUSPEND":
return "城市异常暂停";
case "REGION_VARIANCE_WARNING":
return "省/地区异常警告";
case "REGION_VARIANCE_SUSPEND":
return "省/地区异常暂停";
case "COUNTRY_VARIANCE_WARNING":
return "国家异常警告";
case "COUNTRY_VARIANCE_SUSPEND":
return "国家异常暂停";
}
}
function reviewStatusLabel(status: SubscriptionRiskEvent["reviewStatus"]) {
switch (status) {
case "OPEN":
return "待处理";
case "ACKNOWLEDGED":
return "已确认";
case "RESOLVED":
return "已解决";
}
}
function reviewStatusTone(status: SubscriptionRiskEvent["reviewStatus"]): StatusTone {
if (status === "RESOLVED") return "success";
if (status === "ACKNOWLEDGED") return "info";
return "warning";
}
function finalActionLabel(action: SubscriptionRiskEvent["finalAction"]) {
switch (action) {
case "RESTORE_ACCESS":
return "已解除限制";
case "KEEP_RESTRICTED":
return "保持封禁/暂停";
default:
return null;
}
}
function finalActionTone(action: SubscriptionRiskEvent["finalAction"]): StatusTone {
if (action === "RESTORE_ACCESS") return "success";
if (action === "KEEP_RESTRICTED") return "warning";
return "neutral";
}
function MetricStrip({ events }: { events: SubscriptionRiskEventRow[] }) {
const open = events.filter((event) => event.reviewStatus === "OPEN").length;
const suspended = events.filter((event) => event.level === "SUSPENDED").length;
const restricted = events.filter((event) => event.userRestrictionActive).length;
const reports = events.filter((event) => event.reportSentAt).length;
return (
<section className="grid gap-px overflow-hidden rounded-xl border border-border/70 bg-border/70 sm:grid-cols-4">
{[
["本页待处理", open],
["已暂停事件", suspended],
["用户端限制", restricted],
["已发送报告", reports],
].map(([label, value]) => (
<div key={label} className="bg-card px-4 py-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 font-mono text-2xl font-semibold tracking-[-0.03em]">{value}</p>
</div>
))}
</section>
);
}
function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.subscription) {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone="info">{kindLabel(event.kind)}</StatusBadge>
</div>
<p className="text-sm text-muted-foreground"></p>
</div>
);
}
return (
<div className="space-y-2">
<Link href={"/admin/subscriptions/" + event.subscription.id} className="break-words font-medium hover:underline">
{event.subscription.plan.name}
</Link>
<div className="flex flex-wrap items-center gap-2">
<SubscriptionTypeBadge type={event.subscription.plan.type} />
<SubscriptionStatusBadge status={event.subscription.status} />
</div>
<p className="text-xs text-muted-foreground">{formatDateShort(event.subscription.endDate)}</p>
</div>
);
}
function UserBlock({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.user) {
return <p className="text-sm text-muted-foreground"></p>;
}
return (
<div className="space-y-2">
<Link href={"/admin/users/" + event.user.id} className="block break-all font-medium hover:underline">
{event.user.email}
</Link>
<p className="break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
<UserStatusBadge status={event.user.status} />
</div>
);
}
function ReviewState({ event }: { event: SubscriptionRiskEventRow }) {
const finalLabel = finalActionLabel(event.finalAction);
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
{event.reportSentAt && <StatusBadge tone="info"></StatusBadge>}
{event.userRestrictionActive && <StatusBadge tone="danger"></StatusBadge>}
{finalLabel && <StatusBadge tone={finalActionTone(event.finalAction)}>{finalLabel}</StatusBadge>}
</div>
{(event.reviewedByEmail || event.reviewNote) && (
<div className="border-t border-border/60 pt-3 text-xs leading-5 text-muted-foreground">
{event.reviewedByEmail && <p className="break-all">{event.reviewedByEmail}</p>}
{event.reviewedAt && <p>{formatDate(event.reviewedAt)}</p>}
{event.reviewNote && <p className="mt-1 line-clamp-4 whitespace-pre-wrap text-foreground/70">{event.reviewNote}</p>}
</div>
)}
</div>
);
}
function RiskStat({ label, value }: { label: string; value: string | number }) {
return (
<span className="min-w-0 rounded-lg border border-border/70 bg-muted/20 px-2.5 py-1.5">
<span className="block text-[0.68rem] leading-none text-muted-foreground">{label}</span>
<span className="mt-1 block truncate font-mono text-sm font-semibold leading-none">{value}</span>
</span>
);
}
function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) {
const summary = event.geoSummary;
const userLabel = event.user?.email ?? "未知用户";
const scopeLabel = event.subscription?.plan.name ?? "总订阅";
return (
<details className="surface-card group overflow-hidden rounded-xl">
<summary className="flex cursor-pointer list-none items-start gap-4 p-4 text-left [&::-webkit-details-marker]:hidden sm:p-5">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>
{reasonLabel(event.reason)}
</StatusBadge>
<StatusBadge tone="neutral">{kindLabel(event.kind)}</StatusBadge>
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
{event.userRestrictionActive && <StatusBadge tone="danger"></StatusBadge>}
{event.reportSentAt && <StatusBadge tone="info"></StatusBadge>}
<span className="text-xs text-muted-foreground">{formatDate(event.createdAt)}</span>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className="min-w-0 space-y-1.5">
<p className="line-clamp-2 text-sm font-semibold leading-6">{event.message}</p>
<p className="truncate text-xs text-muted-foreground">
{userLabel} · {scopeLabel} · IP{event.ip || "未知 IP"}
</p>
</div>
<div className="grid grid-cols-4 gap-2 text-right sm:flex sm:justify-end">
<RiskStat label="国家" value={summary.uniqueCountryCount} />
<RiskStat label="省区" value={summary.uniqueRegionCount} />
<RiskStat label="城市" value={summary.uniqueCityCount} />
<RiskStat label="IP" value={summary.uniqueIpCount} />
</div>
</div>
</div>
<span className="mt-1 flex shrink-0 items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
<span className="hidden sm:inline"></span>
<ChevronDown className="size-4 transition-transform group-open:rotate-180" />
</span>
</summary>
<div className="border-t border-border/70">
<div className="grid xl:grid-cols-[minmax(0,0.85fr)_minmax(24rem,1.15fr)_minmax(18rem,0.7fr)]">
<section className="space-y-5 p-5">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground"></p>
<UserBlock event={event} />
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground"></p>
<EventScope event={event} />
</div>
</div>
</section>
<section className="border-y border-border/70 bg-muted/10 p-5 xl:border-x xl:border-y-0">
<SubscriptionRiskGeoDetails summary={summary} />
</section>
<aside className="space-y-5 p-5">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<ReviewState event={event} />
</div>
<div className="border-t border-border/60 pt-4">
<SubscriptionRiskReviewActions
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>
</aside>
</div>
</div>
</details>
);
}
export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEventRow[] }) {
if (events.length === 0) {
return (
<EmptyState
title="暂无订阅风控事件"
description="订阅链接出现跨城市、跨省份或跨国家访问异常后,会在这里进入人工跟进队列。"
/>
);
}
return (
<div className="space-y-4">
<MetricStrip events={events} />
<div className="space-y-4">
{events.map((event) => (
<RiskEventCard key={event.id} event={event} />
))}
</div>
</div>
);
}